openreader/src/components/doclist/window/FinderToolbar.tsx
Richard R eb8820d888 feat(doclist): enhance document list UI with overlay uploader and improved file size formatting
Integrate DocumentUploader in overlay mode to wrap all document views, enabling drag-and-drop uploads across the entire list area. Refine file size formatting to support KB, MB, and GB units in all views and tiles. Standardize button scaling and padding for consistent UI rhythm. Update DocumentPreview and detail panels to display both type and human-readable size. Add overlay drag visual feedback for uploads.

These changes improve usability, visual clarity, and consistency in the document list interface.
2026-05-27 19:35:03 -06:00

229 lines
8.1 KiB
TypeScript

'use client';
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
import type { IconSize, SortBy, SortDirection, ViewMode } from '@/types/documents';
import {
IconsViewIcon,
ListViewIcon,
ColumnsViewIcon,
GalleryViewIcon,
SearchIcon,
FolderPlusIcon,
HamburgerIcon,
} from './finderIcons';
import { ChevronUpDownIcon } from '@/components/icons/Icons';
import type { ReactNode } from 'react';
interface FinderToolbarProps {
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
iconSize: IconSize;
onIconSizeChange: (size: IconSize) => void;
sortBy: SortBy;
sortDirection: SortDirection;
onSortByChange: (s: SortBy) => void;
onSortDirectionToggle: () => void;
query: string;
onQueryChange: (q: string) => void;
onNewFolder: () => void;
onToggleSidebar: () => void;
/** True when the columns view should be disabled (mobile viewport). */
isNarrow: boolean;
/** App-level content rendered at the far left (brand/logo). */
leftSlot?: ReactNode;
/** App-level content rendered at the far right (settings, user menu). */
rightSlot?: ReactNode;
}
const VIEW_BUTTONS: Array<{ value: ViewMode; label: string; Icon: typeof IconsViewIcon }> = [
{ value: 'icons', label: 'Icons', Icon: IconsViewIcon },
{ value: 'list', label: 'List', Icon: ListViewIcon },
{ value: 'columns', label: 'Columns', Icon: ColumnsViewIcon },
{ value: 'gallery', label: 'Gallery', Icon: GalleryViewIcon },
];
const SORT_OPTIONS: Array<{ value: SortBy; label: string; asc: string; desc: string }> = [
{ value: 'name', label: 'Name', asc: 'A → Z', desc: 'Z → A' },
{ value: 'type', label: 'Kind', asc: 'A → Z', desc: 'Z → A' },
{ value: 'date', label: 'Modified', asc: 'Oldest', desc: 'Newest' },
{ value: 'size', label: 'Size', asc: 'Smallest', desc: 'Largest' },
];
const ICON_SIZES: Array<{ value: IconSize; label: string }> = [
{ value: 'sm', label: 'S' },
{ value: 'md', label: 'M' },
{ value: 'lg', label: 'L' },
{ value: 'xl', label: 'XL' },
];
// Match SettingsModal / UserMenu trigger sizing exactly so all bar buttons share one rhythm.
const TOOLBAR_BTN =
'inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-out hover:scale-[1.01]';
const TOOLBAR_BTN_INACTIVE =
'border-offbase text-foreground hover:text-accent hover:border-accent hover:bg-offbase';
// Pill-grouped segmented control. Outer pill carries the border; inner segments are
// borderless and rely on bg/text color to show active/hover. Sized so the whole pill
// matches the height of a standalone TOOLBAR_BTN.
const PILL = 'inline-flex items-center rounded-md border border-offbase bg-base p-0.5 gap-0.5 shrink-0';
const PILL_SEGMENT =
'inline-flex items-center justify-center rounded-[5px] text-xs transition-colors duration-200 ease-out';
const PILL_SEGMENT_INACTIVE = 'text-muted hover:bg-offbase hover:text-accent';
const PILL_SEGMENT_ACTIVE = 'bg-offbase text-accent';
export function FinderToolbar({
viewMode,
onViewModeChange,
iconSize,
onIconSizeChange,
sortBy,
sortDirection,
onSortByChange,
onSortDirectionToggle,
query,
onQueryChange,
onNewFolder,
onToggleSidebar,
isNarrow,
leftSlot,
rightSlot,
}: FinderToolbarProps) {
const currentSort = SORT_OPTIONS.find((o) => o.value === sortBy) ?? SORT_OPTIONS[0];
const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc;
return (
<div className="min-h-10 px-2 sm:px-3 py-1 flex items-center gap-1.5 sm:gap-2 bg-base border-b border-offbase">
{leftSlot && (
<div className="shrink-0 flex items-center gap-2 pr-1 sm:pr-2 sm:border-r sm:border-offbase">
{leftSlot}
</div>
)}
<button
type="button"
onClick={onToggleSidebar}
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} shrink-0`}
aria-label="Toggle sidebar"
title="Toggle sidebar"
>
<HamburgerIcon className="w-4 h-4" />
</button>
<div className={PILL}>
{VIEW_BUTTONS.map(({ value, label, Icon }) => {
const disabled = value === 'columns' && isNarrow;
const active = viewMode === value;
return (
<button
key={value}
type="button"
disabled={disabled}
onClick={() => onViewModeChange(value)}
aria-pressed={active}
aria-label={`${label} view`}
title={disabled ? `${label} (desktop only)` : `${label} view`}
className={
PILL_SEGMENT +
' h-6 w-7 ' +
(active ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE) +
(disabled ? ' opacity-40 cursor-not-allowed hover:bg-transparent hover:text-muted' : '')
}
>
<Icon className="w-4 h-4" />
</button>
);
})}
</div>
{viewMode === 'icons' && (
<div className={`${PILL} hidden sm:inline-flex`}>
{ICON_SIZES.map(({ value, label }) => {
const active = iconSize === value;
return (
<button
key={value}
type="button"
onClick={() => onIconSizeChange(value)}
aria-pressed={active}
aria-label={`Icon size ${label}`}
className={
PILL_SEGMENT +
' h-6 min-w-[26px] px-1.5 font-semibold tracking-wide ' +
(active ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
}
>
{label}
</button>
);
})}
</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`}
>
<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" />
<div className="hidden sm:flex items-center gap-1.5 px-2 py-1 rounded-md bg-background border border-offbase hover:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:border-accent transition-colors duration-200 ease-out w-[160px] md:w-[200px]">
<SearchIcon className="w-3.5 h-3.5 text-muted shrink-0" />
<input
type="search"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search"
className="flex-1 min-w-0 bg-transparent outline-none text-xs text-foreground placeholder:text-muted"
/>
</div>
<button
type="button"
onClick={onNewFolder}
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 shrink-0`}
title="New folder"
>
<FolderPlusIcon className="w-4 h-4" />
<span className="hidden md:inline">New Folder</span>
</button>
{rightSlot && (
<div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-offbase ml-0.5">
{rightSlot}
</div>
)}
</div>
);
}