Converge sidebar and reader controls
This commit is contained in:
parent
cc0d460f6b
commit
9038562971
12 changed files with 291 additions and 227 deletions
|
|
@ -52,14 +52,15 @@ import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
|
||||||
import { useSharedProviders } from '@/hooks/useSharedProviders';
|
import { useSharedProviders } from '@/hooks/useSharedProviders';
|
||||||
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
|
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
|
||||||
import {
|
import {
|
||||||
|
SidebarNav,
|
||||||
|
SidebarNavItem,
|
||||||
|
SegmentedControl,
|
||||||
inputClass,
|
inputClass,
|
||||||
|
buttonClass,
|
||||||
listboxButtonClass,
|
listboxButtonClass,
|
||||||
listboxOptionClass,
|
listboxOptionClass,
|
||||||
listboxOptionsClass,
|
listboxOptionsClass,
|
||||||
segmentedButtonClass,
|
} from '@/components/ui';
|
||||||
segmentedGroupClass,
|
|
||||||
} from '@/components/formPrimitives';
|
|
||||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { fetchChangelogManifest, fetchChangelogReleaseBody } from '@/lib/client/changelog';
|
import { fetchChangelogManifest, fetchChangelogReleaseBody } from '@/lib/client/changelog';
|
||||||
|
|
@ -572,50 +573,42 @@ export function SettingsModal({
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Mobile: 2x2 grid nav */}
|
{/* Mobile nav */}
|
||||||
<div className="grid grid-cols-2 gap-1 sm:hidden border-b border-line-soft bg-background p-2">
|
<SidebarNav layout="grid" className="sm:hidden border-b border-line-soft bg-background p-2">
|
||||||
{visibleSections.map((section) => {
|
{visibleSections.map((section) => {
|
||||||
const Icon = section.icon;
|
const Icon = section.icon;
|
||||||
return (
|
return (
|
||||||
<button
|
<SidebarNavItem
|
||||||
|
compact
|
||||||
key={section.id}
|
key={section.id}
|
||||||
onClick={() => setActiveSection(section.id)}
|
onClick={() => setActiveSection(section.id)}
|
||||||
className={`flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
active={activeSection === section.id}
|
||||||
activeSection === section.id
|
icon={<Icon className="w-3.5 h-3.5" />}
|
||||||
? 'bg-accent text-background'
|
label={section.label}
|
||||||
: 'text-foreground hover:bg-accent-wash hover:text-accent'
|
/>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="w-3.5 h-3.5" />
|
|
||||||
{section.label}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</SidebarNav>
|
||||||
|
|
||||||
<div className="flex flex-row h-[490px]">
|
<div className="flex flex-row h-[490px]">
|
||||||
{/* Desktop: vertical sidebar */}
|
{/* Desktop: vertical sidebar */}
|
||||||
<nav className="hidden sm:block w-44 shrink-0 border-r border-line-soft bg-background p-2">
|
<nav className="hidden sm:block w-44 shrink-0 border-r border-line-soft bg-background p-2">
|
||||||
<div className="flex flex-col gap-1">
|
<SidebarNav>
|
||||||
{visibleSections.map((section) => {
|
{visibleSections.map((section) => {
|
||||||
const Icon = section.icon;
|
const Icon = section.icon;
|
||||||
const active = activeSection === section.id;
|
const active = activeSection === section.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<SidebarNavItem
|
||||||
key={section.id}
|
key={section.id}
|
||||||
onClick={() => setActiveSection(section.id)}
|
onClick={() => setActiveSection(section.id)}
|
||||||
className={`w-full flex items-center gap-2.5 text-left px-2.5 py-1.5 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${
|
active={active}
|
||||||
active
|
icon={<Icon className="w-4 h-4" />}
|
||||||
? 'bg-accent text-background'
|
label={section.label}
|
||||||
: 'text-foreground hover:bg-surface hover:text-accent'
|
className="whitespace-nowrap"
|
||||||
}`}
|
/>
|
||||||
>
|
|
||||||
<Icon className="w-4 h-4 shrink-0" />
|
|
||||||
{section.label}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</SidebarNav>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|
@ -1166,30 +1159,16 @@ export function SettingsModal({
|
||||||
{/* Admin Section */}
|
{/* Admin Section */}
|
||||||
{activeSection === 'admin' && isAdmin && (
|
{activeSection === 'admin' && isAdmin && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div
|
<SegmentedControl
|
||||||
role="radiogroup"
|
value={adminSubTab}
|
||||||
aria-label="Admin tab"
|
options={[
|
||||||
className={`${segmentedGroupClass} grid-cols-2`}
|
{ value: 'providers', label: 'Shared providers' },
|
||||||
>
|
{ value: 'features', label: 'Site features' },
|
||||||
{([
|
]}
|
||||||
{ id: 'providers', label: 'Shared providers' },
|
onChange={setAdminSubTab}
|
||||||
{ id: 'features', label: 'Site features' },
|
ariaLabel="Admin tab"
|
||||||
] as { id: AdminSubTab; label: string }[]).map((tab) => {
|
className="grid-cols-2"
|
||||||
const active = adminSubTab === tab.id;
|
/>
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={active}
|
|
||||||
onClick={() => setAdminSubTab(tab.id)}
|
|
||||||
className={segmentedButtonClass(active)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{adminSubTab === 'providers' && <AdminProvidersPanel />}
|
{adminSubTab === 'providers' && <AdminProvidersPanel />}
|
||||||
{adminSubTab === 'features' && <AdminFeaturesPanel />}
|
{adminSubTab === 'features' && <AdminFeaturesPanel />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import type { Folder, SidebarFilter } from '@/types/documents';
|
||||||
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
|
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
|
||||||
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
|
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
|
||||||
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
||||||
import { IconButton, MenuItemClass, Sidebar as SidebarShell } from '@/components/ui';
|
import { IconButton, MenuItemClass, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui';
|
||||||
|
|
||||||
interface FinderSidebarProps {
|
interface FinderSidebarProps {
|
||||||
filter: SidebarFilter;
|
filter: SidebarFilter;
|
||||||
|
|
@ -31,60 +31,6 @@ interface FinderSidebarProps {
|
||||||
const MIN_WIDTH = 168;
|
const MIN_WIDTH = 168;
|
||||||
const MAX_WIDTH = 320;
|
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 duration-base ease-standard text-left ' +
|
|
||||||
(active
|
|
||||||
? 'border-accent-line bg-surface-sunken text-accent'
|
|
||||||
: 'border-transparent bg-transparent text-foreground hover:border-accent-line hover:text-accent') +
|
|
||||||
(isDropTarget ? ' ring-1 ring-accent-line' : '')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
'w-4 h-4 shrink-0 flex items-center justify-center transition-colors duration-base ' +
|
|
||||||
(active ? 'text-accent' : 'text-soft group-hover:text-accent')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<span className="truncate flex-1">{label}</span>
|
|
||||||
{typeof count === 'number' && count > 0 && (
|
|
||||||
<span
|
|
||||||
className={`text-[10px] text-soft tabular-nums transition-transform duration-base ease-standard ${countClassName ?? ''}`}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{trailing}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FolderRow({
|
function FolderRow({
|
||||||
folder,
|
folder,
|
||||||
active,
|
active,
|
||||||
|
|
@ -123,7 +69,8 @@ function FolderRow({
|
||||||
ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
|
ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
|
||||||
className="group/folder relative"
|
className="group/folder relative"
|
||||||
>
|
>
|
||||||
<SidebarRow
|
<SidebarNavItem
|
||||||
|
compact
|
||||||
active={active}
|
active={active}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
icon={<FolderIcon className="w-3.5 h-3.5" />}
|
icon={<FolderIcon className="w-3.5 h-3.5" />}
|
||||||
|
|
@ -150,28 +97,6 @@ function FolderRow({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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-soft 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({
|
export function FinderSidebar({
|
||||||
filter,
|
filter,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
|
|
@ -210,14 +135,15 @@ export function FinderSidebar({
|
||||||
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"
|
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">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="p-2 flex flex-col gap-0.5">
|
<SidebarNav className="p-2">
|
||||||
{topSlot && (
|
{topSlot && (
|
||||||
<div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
|
<div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
{topSlot}
|
{topSlot}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<SectionHeader isFirst={!!topSlot}>Library</SectionHeader>
|
<SidebarNavGroup isFirst={!!topSlot}>Library</SidebarNavGroup>
|
||||||
<SidebarRow
|
<SidebarNavItem
|
||||||
|
compact
|
||||||
active={filter === 'all'}
|
active={filter === 'all'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onFilterChange('all');
|
onFilterChange('all');
|
||||||
|
|
@ -227,7 +153,8 @@ export function FinderSidebar({
|
||||||
label="All Documents"
|
label="All Documents"
|
||||||
count={counts.all}
|
count={counts.all}
|
||||||
/>
|
/>
|
||||||
<SidebarRow
|
<SidebarNavItem
|
||||||
|
compact
|
||||||
active={filter === 'recents'}
|
active={filter === 'recents'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onFilterChange('recents');
|
onFilterChange('recents');
|
||||||
|
|
@ -237,8 +164,9 @@ export function FinderSidebar({
|
||||||
label="Recently Opened"
|
label="Recently Opened"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionHeader>Kinds</SectionHeader>
|
<SidebarNavGroup>Kinds</SidebarNavGroup>
|
||||||
<SidebarRow
|
<SidebarNavItem
|
||||||
|
compact
|
||||||
active={filter === 'pdf'}
|
active={filter === 'pdf'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onFilterChange('pdf');
|
onFilterChange('pdf');
|
||||||
|
|
@ -248,7 +176,8 @@ export function FinderSidebar({
|
||||||
label="PDF"
|
label="PDF"
|
||||||
count={counts.pdf}
|
count={counts.pdf}
|
||||||
/>
|
/>
|
||||||
<SidebarRow
|
<SidebarNavItem
|
||||||
|
compact
|
||||||
active={filter === 'epub'}
|
active={filter === 'epub'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onFilterChange('epub');
|
onFilterChange('epub');
|
||||||
|
|
@ -258,7 +187,8 @@ export function FinderSidebar({
|
||||||
label="EPUB"
|
label="EPUB"
|
||||||
count={counts.epub}
|
count={counts.epub}
|
||||||
/>
|
/>
|
||||||
<SidebarRow
|
<SidebarNavItem
|
||||||
|
compact
|
||||||
active={filter === 'html'}
|
active={filter === 'html'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onFilterChange('html');
|
onFilterChange('html');
|
||||||
|
|
@ -269,8 +199,8 @@ export function FinderSidebar({
|
||||||
count={counts.html}
|
count={counts.html}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionHeader
|
<SidebarNavGroup
|
||||||
rightSlot={(
|
action={(
|
||||||
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
|
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
|
||||||
<MenuButton
|
<MenuButton
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
|
|
@ -333,7 +263,7 @@ export function FinderSidebar({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Folders
|
Folders
|
||||||
</SectionHeader>
|
</SidebarNavGroup>
|
||||||
{folders.length === 0 ? (
|
{folders.length === 0 ? (
|
||||||
<p className="px-2 py-1 text-[11px] text-soft">No folders yet</p>
|
<p className="px-2 py-1 text-[11px] text-soft">No folders yet</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -354,7 +284,7 @@ export function FinderSidebar({
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</SidebarNav>
|
||||||
</div>
|
</div>
|
||||||
{bottomSlot && (
|
{bottomSlot && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
HamburgerIcon,
|
HamburgerIcon,
|
||||||
} from './finderIcons';
|
} from './finderIcons';
|
||||||
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
||||||
import { Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment, toolbarButtonStyles } from '@/components/ui';
|
import { SearchField, Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment, listboxOptionClass, listboxOptionsClass, toolbarButtonStyles } from '@/components/ui';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface FinderToolbarProps {
|
interface FinderToolbarProps {
|
||||||
|
|
@ -151,19 +151,12 @@ export function FinderToolbar({
|
||||||
<span>{currentSort.label}</span>
|
<span>{currentSort.label}</span>
|
||||||
<ChevronUpDownIcon className="h-3 w-3 opacity-60" />
|
<ChevronUpDownIcon className="h-3 w-3 opacity-60" />
|
||||||
</ListboxButton>
|
</ListboxButton>
|
||||||
<ListboxOptions
|
<ListboxOptions anchor="bottom end" className={listboxOptionsClass}>
|
||||||
anchor="bottom end"
|
|
||||||
className="z-50 mt-1 rounded-md bg-surface-sunken border border-line shadow-elev-2 p-1 focus:outline-none"
|
|
||||||
>
|
|
||||||
{SORT_OPTIONS.map((opt) => (
|
{SORT_OPTIONS.map((opt) => (
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
className={({ active, selected }) =>
|
className={({ active, selected }) => listboxOptionClass(active || selected)}
|
||||||
`cursor-pointer select-none rounded-sm py-1.5 px-2.5 text-xs ${
|
|
||||||
active ? 'bg-surface-sunken text-accent' : 'text-foreground'
|
|
||||||
} ${selected ? 'font-semibold' : ''}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</ListboxOption>
|
</ListboxOption>
|
||||||
|
|
@ -175,16 +168,13 @@ export function FinderToolbar({
|
||||||
|
|
||||||
<div className="flex-1 min-w-0" />
|
<div className="flex-1 min-w-0" />
|
||||||
|
|
||||||
<div className="hidden sm:flex items-center gap-1.5 px-2 py-1 rounded-md bg-surface-sunken border border-line hover:border-accent-line focus-within:ring-1 focus-within:ring-accent-line focus-within:border-accent-line transition-colors duration-base ease-standard w-[160px] md:w-[200px]">
|
<SearchField
|
||||||
<SearchIcon className="w-3.5 h-3.5 text-soft shrink-0" />
|
value={query}
|
||||||
<input
|
onChange={(e) => onQueryChange(e.target.value)}
|
||||||
type="search"
|
placeholder="Search"
|
||||||
value={query}
|
className="hidden w-[160px] md:w-[200px] sm:flex"
|
||||||
onChange={(e) => onQueryChange(e.target.value)}
|
icon={<SearchIcon className="w-3.5 h-3.5" />}
|
||||||
placeholder="Search"
|
/>
|
||||||
className="flex-1 min-w-0 bg-transparent outline-none text-xs text-foreground placeholder:text-soft"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{rightSlot && (
|
{rightSlot && (
|
||||||
<div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-line ml-0.5">
|
<div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-line ml-0.5">
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Fragment } from 'react';
|
||||||
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons';
|
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons';
|
||||||
import { ZoomControl } from '@/components/documents/ZoomControl';
|
import { ZoomControl } from '@/components/documents/ZoomControl';
|
||||||
import { UserMenu } from '@/components/auth/UserMenu';
|
import { UserMenu } from '@/components/auth/UserMenu';
|
||||||
|
import { IconButton, MenuItemClass, ToolbarButton, menuPanelClass } from '@/components/ui';
|
||||||
|
|
||||||
interface DocumentHeaderMenuProps {
|
interface DocumentHeaderMenuProps {
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
|
|
@ -47,45 +48,33 @@ export function DocumentHeaderMenu({
|
||||||
max={maxZoom}
|
max={maxZoom}
|
||||||
/>
|
/>
|
||||||
{onOpenSegments && (
|
{onOpenSegments && (
|
||||||
<button
|
<ToolbarButton
|
||||||
onClick={onOpenSegments}
|
onClick={onOpenSegments}
|
||||||
className={`inline-flex items-center py-1 px-2 rounded-md border bg-surface text-xs transition duration-base ease-standard ${
|
active={isSegmentsOpen}
|
||||||
isSegmentsOpen
|
|
||||||
? 'border-accent text-accent bg-surface-sunken'
|
|
||||||
: 'border-line text-foreground hover:bg-accent-wash hover:text-accent'
|
|
||||||
}`}
|
|
||||||
aria-label={isSegmentsOpen ? 'Hide segments sidebar' : 'Open segments sidebar'}
|
aria-label={isSegmentsOpen ? 'Hide segments sidebar' : 'Open segments sidebar'}
|
||||||
title={isSegmentsOpen ? 'Hide Segments' : 'Segments'}
|
title={isSegmentsOpen ? 'Hide Segments' : 'Segments'}
|
||||||
>
|
>
|
||||||
<ListIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
|
<ListIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
|
||||||
</button>
|
</ToolbarButton>
|
||||||
)}
|
)}
|
||||||
{showAudiobookExport && onOpenAudiobook && (
|
{showAudiobookExport && onOpenAudiobook && (
|
||||||
<button
|
<ToolbarButton
|
||||||
onClick={onOpenAudiobook}
|
onClick={onOpenAudiobook}
|
||||||
className={`inline-flex items-center py-1 px-2 rounded-md border bg-surface text-xs transition duration-base ease-standard ${
|
active={isAudiobookOpen}
|
||||||
isAudiobookOpen
|
|
||||||
? 'border-accent text-accent bg-surface-sunken'
|
|
||||||
: 'border-line text-foreground hover:bg-accent-wash hover:text-accent'
|
|
||||||
}`}
|
|
||||||
aria-label={isAudiobookOpen ? 'Hide audiobook export' : 'Open audiobook export'}
|
aria-label={isAudiobookOpen ? 'Hide audiobook export' : 'Open audiobook export'}
|
||||||
title={isAudiobookOpen ? 'Hide Export Audiobook' : 'Export Audiobook'}
|
title={isAudiobookOpen ? 'Hide Export Audiobook' : 'Export Audiobook'}
|
||||||
>
|
>
|
||||||
<DownloadIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
|
<DownloadIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
|
||||||
</button>
|
</ToolbarButton>
|
||||||
)}
|
)}
|
||||||
<button
|
<ToolbarButton
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
className={`inline-flex items-center py-1 px-2 rounded-md border bg-surface text-xs transition duration-base ease-standard ${
|
active={isSettingsOpen}
|
||||||
isSettingsOpen
|
|
||||||
? 'border-accent text-accent bg-surface-sunken'
|
|
||||||
: 'border-line text-foreground hover:bg-accent-wash hover:text-accent'
|
|
||||||
}`}
|
|
||||||
aria-label={isSettingsOpen ? 'Hide settings' : 'Open settings'}
|
aria-label={isSettingsOpen ? 'Hide settings' : 'Open settings'}
|
||||||
title={isSettingsOpen ? 'Hide Settings' : 'Settings'}
|
title={isSettingsOpen ? 'Hide Settings' : 'Settings'}
|
||||||
>
|
>
|
||||||
<FileSettingsIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
|
<FileSettingsIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
|
||||||
</button>
|
</ToolbarButton>
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -95,7 +84,9 @@ export function DocumentHeaderMenu({
|
||||||
<div className="sm:hidden flex items-center">
|
<div className="sm:hidden flex items-center">
|
||||||
<Menu as="div" className="relative inline-block text-left">
|
<Menu as="div" className="relative inline-block text-left">
|
||||||
<MenuButton
|
<MenuButton
|
||||||
className="inline-flex items-center justify-center py-1 px-2 rounded-md border border-line bg-surface text-foreground text-xs hover:bg-accent-wash transition duration-base ease-standard hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
as={IconButton}
|
||||||
|
tone="surface"
|
||||||
|
size="sm"
|
||||||
title="Menu"
|
title="Menu"
|
||||||
>
|
>
|
||||||
<DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-base ease-standard hover:text-accent" />
|
<DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-base ease-standard hover:text-accent" />
|
||||||
|
|
@ -109,7 +100,7 @@ export function DocumentHeaderMenu({
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<MenuItems className="absolute right-0 mt-2 min-w-max origin-top-right divide-y divide-line-soft rounded-md bg-surface shadow-elev-2 ring-1 ring-line-soft focus:outline-none z-50">
|
<MenuItems className={`${menuPanelClass} absolute right-0 z-50 mt-2 min-w-max origin-top-right divide-y divide-line-soft focus:outline-none`}>
|
||||||
{/* Zoom Controls Section */}
|
{/* Zoom Controls Section */}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<p className="text-xs font-medium text-soft mb-2">Zoom / Padding</p>
|
<p className="text-xs font-medium text-soft mb-2">Zoom / Padding</p>
|
||||||
|
|
@ -138,8 +129,7 @@ export function DocumentHeaderMenu({
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<button
|
<button
|
||||||
onClick={onOpenSegments}
|
onClick={onOpenSegments}
|
||||||
className={`${active || isSegmentsOpen ? 'bg-surface-sunken text-accent' : 'text-foreground'
|
className={MenuItemClass(active || isSegmentsOpen)}
|
||||||
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
|
||||||
>
|
>
|
||||||
<ListIcon className="h-4 w-4" />
|
<ListIcon className="h-4 w-4" />
|
||||||
{isSegmentsOpen ? 'Hide Segments' : 'Segments'}
|
{isSegmentsOpen ? 'Hide Segments' : 'Segments'}
|
||||||
|
|
@ -152,8 +142,7 @@ export function DocumentHeaderMenu({
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<button
|
<button
|
||||||
onClick={onOpenAudiobook}
|
onClick={onOpenAudiobook}
|
||||||
className={`${active || isAudiobookOpen ? 'bg-surface-sunken text-accent' : 'text-foreground'
|
className={MenuItemClass(active || isAudiobookOpen)}
|
||||||
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
|
||||||
>
|
>
|
||||||
<DownloadIcon className="h-4 w-4" />
|
<DownloadIcon className="h-4 w-4" />
|
||||||
{isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
|
{isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
|
||||||
|
|
@ -165,8 +154,7 @@ export function DocumentHeaderMenu({
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<button
|
<button
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
className={`${active || isSettingsOpen ? 'bg-surface-sunken text-accent' : 'text-foreground'
|
className={MenuItemClass(active || isSettingsOpen)}
|
||||||
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
|
||||||
>
|
>
|
||||||
<FileSettingsIcon className="h-4 w-4" />
|
<FileSettingsIcon className="h-4 w-4" />
|
||||||
{isSettingsOpen ? 'Hide Settings' : 'Settings'}
|
{isSettingsOpen ? 'Hide Settings' : 'Settings'}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@ import {
|
||||||
clampSegmentPreloadSentenceLookahead,
|
clampSegmentPreloadSentenceLookahead,
|
||||||
clampTtsSegmentMaxBlockLength,
|
clampTtsSegmentMaxBlockLength,
|
||||||
} from '@/types/config';
|
} from '@/types/config';
|
||||||
import { Section, ToggleRow, CheckItem, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives';
|
import { Section, ToggleRow, CheckItem, SegmentedControl, buttonClass, rangeInputClass } from '@/components/ui';
|
||||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
|
||||||
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
|
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
|
||||||
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
|
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
|
||||||
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
|
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
|
||||||
|
|
@ -51,8 +50,6 @@ const viewTypeTextMapping = [
|
||||||
{ id: 'scroll', name: 'Continuous Scroll' },
|
{ id: 'scroll', name: 'Continuous Scroll' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const rangeInputClassName = 'w-full bg-offbase rounded-md appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-md [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-md [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent';
|
|
||||||
|
|
||||||
type RangeSettingProps = {
|
type RangeSettingProps = {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
value: number;
|
||||||
|
|
@ -87,7 +84,7 @@ function RangeSetting({
|
||||||
step={step}
|
step={step}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(Number(event.target.value))}
|
onChange={(event) => onChange(Number(event.target.value))}
|
||||||
className={`flex-1 ${rangeInputClassName}`}
|
className={`flex-1 ${rangeInputClass}`}
|
||||||
/>
|
/>
|
||||||
<span className={`${valueWidth} text-xs font-semibold text-right text-foreground`}>{formatter(value)}</span>
|
<span className={`${valueWidth} text-xs font-semibold text-right text-foreground`}>{formatter(value)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -163,27 +160,13 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
||||||
>
|
>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">Page mode</label>
|
<label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">Page mode</label>
|
||||||
<div
|
<SegmentedControl
|
||||||
role="radiogroup"
|
value={selectedView.id as ViewType}
|
||||||
aria-label="Page mode"
|
options={viewTypeTextMapping.map((view) => ({ value: view.id as ViewType, label: view.name }))}
|
||||||
className={`${segmentedGroupClass} grid-cols-3`}
|
onChange={(nextViewType) => updateConfigKey('viewType', nextViewType)}
|
||||||
>
|
ariaLabel="Page mode"
|
||||||
{viewTypeTextMapping.map((view) => {
|
className="grid-cols-3"
|
||||||
const active = selectedView.id === view.id;
|
/>
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={view.id}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={active}
|
|
||||||
onClick={() => updateConfigKey('viewType', view.id as ViewType)}
|
|
||||||
className={segmentedButtonClass(active)}
|
|
||||||
>
|
|
||||||
{view.name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{selectedView.id === 'scroll' ? (
|
{selectedView.id === 'scroll' ? (
|
||||||
<p className="text-xs text-warning">Scroll mode may be slower on large PDFs.</p>
|
<p className="text-xs text-warning">Scroll mode may be slower on large PDFs.</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,12 @@ export * from './input';
|
||||||
export * from './menu';
|
export * from './menu';
|
||||||
export * from './popover';
|
export * from './popover';
|
||||||
export * from './range';
|
export * from './range';
|
||||||
|
export * from './search-field';
|
||||||
export * from './section';
|
export * from './section';
|
||||||
export * from './select';
|
export * from './select';
|
||||||
|
export * from './segmented-control';
|
||||||
export * from './sidebar';
|
export * from './sidebar';
|
||||||
|
export * from './sidebar-nav';
|
||||||
export * from './surface';
|
export * from './surface';
|
||||||
export * from './switch';
|
export * from './switch';
|
||||||
export * from './tokens';
|
export * from './tokens';
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import type { HTMLAttributes, ReactNode } from 'react';
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
import { cn } from './cn';
|
import { cn } from './cn';
|
||||||
|
|
||||||
|
export const menuPanelClass = 'rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft';
|
||||||
|
|
||||||
export function Menu({
|
export function Menu({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
|
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft', className)} {...props}>
|
<div className={cn(menuPanelClass, className)} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,7 @@ export const rangeInputClass = cn(
|
||||||
'[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-lg [&::-moz-range-track]:bg-surface-sunken',
|
'[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-lg [&::-moz-range-track]:bg-surface-sunken',
|
||||||
'[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-accent',
|
'[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-accent',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export function rangeInputClassName(className?: string) {
|
||||||
|
return cn(rangeInputClass, className);
|
||||||
|
}
|
||||||
|
|
|
||||||
32
src/components/ui/search-field.tsx
Normal file
32
src/components/ui/search-field.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from './cn';
|
||||||
|
import { focusRing, motionColors } from './tokens';
|
||||||
|
|
||||||
|
export function SearchField({
|
||||||
|
icon,
|
||||||
|
className,
|
||||||
|
inputClassName,
|
||||||
|
...props
|
||||||
|
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> & {
|
||||||
|
icon?: ReactNode;
|
||||||
|
inputClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={cn(
|
||||||
|
'flex min-w-0 items-center gap-1.5 rounded-md border border-line bg-surface-sunken px-2 py-1',
|
||||||
|
'focus-within:border-accent-line focus-within:ring-1 focus-within:ring-accent-line hover:border-accent-line',
|
||||||
|
focusRing,
|
||||||
|
motionColors,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon ? <span className="shrink-0 text-soft">{icon}</span> : null}
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className={cn('min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-soft', inputClassName)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/ui/segmented-control.tsx
Normal file
47
src/components/ui/segmented-control.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from './cn';
|
||||||
|
import { focusRing, motionColors } from './tokens';
|
||||||
|
|
||||||
|
export const segmentedGroupClass = 'grid gap-1 rounded-full border border-line bg-surface-sunken p-1';
|
||||||
|
|
||||||
|
export const segmentedButtonClass = (active: boolean) =>
|
||||||
|
cn(
|
||||||
|
'rounded-full px-2.5 py-1.5 text-xs font-medium',
|
||||||
|
focusRing,
|
||||||
|
motionColors,
|
||||||
|
active ? 'bg-accent text-background' : 'text-soft hover:bg-accent-wash hover:text-foreground',
|
||||||
|
);
|
||||||
|
|
||||||
|
export function SegmentedControl<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
ariaLabel,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: T;
|
||||||
|
options: Array<{ value: T; label: ReactNode }>;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div role="radiogroup" aria-label={ariaLabel} className={cn(segmentedGroupClass, className)}>
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = value === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
className={segmentedButtonClass(active)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
|
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
|
||||||
import { cn } from './cn';
|
import { cn } from './cn';
|
||||||
|
export { segmentedButtonClass, segmentedGroupClass } from './segmented-control';
|
||||||
|
|
||||||
export const listboxButtonClass =
|
export const listboxButtonClass =
|
||||||
'relative w-full cursor-pointer rounded-md bg-surface-sunken border border-line py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent-line hover:bg-accent-wash transition-colors duration-fast ease-standard';
|
'relative w-full cursor-pointer rounded-md bg-surface-sunken border border-line py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent-line hover:bg-accent-wash transition-colors duration-fast ease-standard';
|
||||||
|
|
@ -12,14 +13,6 @@ export const listboxOptionsClass =
|
||||||
export const listboxOptionClass = (active: boolean) =>
|
export const listboxOptionClass = (active: boolean) =>
|
||||||
cn('relative cursor-pointer select-none rounded-sm py-1.5 pl-9 pr-3 text-sm', active ? 'bg-accent-wash text-foreground' : 'text-foreground');
|
cn('relative cursor-pointer select-none rounded-sm py-1.5 pl-9 pr-3 text-sm', active ? 'bg-accent-wash text-foreground' : 'text-foreground');
|
||||||
|
|
||||||
export const segmentedGroupClass = 'grid gap-1 rounded-full border border-line bg-surface-sunken p-1';
|
|
||||||
|
|
||||||
export const segmentedButtonClass = (active: boolean) =>
|
|
||||||
cn(
|
|
||||||
'rounded-full px-2.5 py-1.5 text-xs font-medium transition-colors duration-fast ease-standard focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
|
|
||||||
active ? 'bg-accent text-background' : 'text-soft hover:bg-accent-wash hover:text-foreground',
|
|
||||||
);
|
|
||||||
|
|
||||||
export function Select({
|
export function Select({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
|
|
||||||
113
src/components/ui/sidebar-nav.tsx
Normal file
113
src/components/ui/sidebar-nav.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from './cn';
|
||||||
|
import { focusRing, motionColors } from './tokens';
|
||||||
|
|
||||||
|
export function SidebarNav({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
layout = 'stack',
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
|
children: ReactNode;
|
||||||
|
layout?: 'stack' | 'grid';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
layout === 'grid' ? 'grid grid-cols-2 gap-1' : 'flex flex-col gap-0.5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarNavGroup({
|
||||||
|
children,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
isFirst = false,
|
||||||
|
...props
|
||||||
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
|
children: ReactNode;
|
||||||
|
action?: ReactNode;
|
||||||
|
isFirst?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'px-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-soft font-semibold leading-none flex items-center justify-between',
|
||||||
|
isFirst ? 'pt-1.5' : 'pt-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span>{children}</span>
|
||||||
|
{action ? <span className="inline-flex shrink-0 items-center leading-none">{action}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarNavItem({
|
||||||
|
active = false,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
countClassName,
|
||||||
|
trailing,
|
||||||
|
isDropTarget = false,
|
||||||
|
className,
|
||||||
|
compact = false,
|
||||||
|
type = 'button',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
active?: boolean;
|
||||||
|
icon?: ReactNode;
|
||||||
|
label?: ReactNode;
|
||||||
|
count?: number;
|
||||||
|
countClassName?: string;
|
||||||
|
trailing?: ReactNode;
|
||||||
|
isDropTarget?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'group w-full min-w-0 border text-left font-medium',
|
||||||
|
'inline-flex items-center transition duration-base ease-standard',
|
||||||
|
focusRing,
|
||||||
|
motionColors,
|
||||||
|
compact ? 'gap-1.5 rounded-md px-2 py-1 text-xs' : 'gap-2 rounded-md px-2.5 py-1.5 text-sm',
|
||||||
|
active
|
||||||
|
? 'border-accent-line bg-surface-sunken text-accent'
|
||||||
|
: 'border-transparent bg-transparent text-foreground hover:border-accent-line hover:bg-accent-wash hover:text-accent',
|
||||||
|
isDropTarget ? 'ring-1 ring-accent-line' : '',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon ? (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 inline-flex items-center justify-center transition-colors duration-base',
|
||||||
|
compact ? 'h-4 w-4' : 'h-5 w-5',
|
||||||
|
active ? 'text-accent' : 'text-soft group-hover:text-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{label ?? children ? <span className="min-w-0 flex-1 truncate">{label ?? children}</span> : null}
|
||||||
|
{typeof count === 'number' && count > 0 ? (
|
||||||
|
<span className={cn('text-[10px] text-soft tabular-nums transition-transform duration-base ease-standard', countClassName)}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{trailing}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue