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 { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
|
||||
import {
|
||||
SidebarNav,
|
||||
SidebarNavItem,
|
||||
SegmentedControl,
|
||||
inputClass,
|
||||
buttonClass,
|
||||
listboxButtonClass,
|
||||
listboxOptionClass,
|
||||
listboxOptionsClass,
|
||||
segmentedButtonClass,
|
||||
segmentedGroupClass,
|
||||
} from '@/components/formPrimitives';
|
||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
||||
} from '@/components/ui';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { fetchChangelogManifest, fetchChangelogReleaseBody } from '@/lib/client/changelog';
|
||||
|
|
@ -572,50 +573,42 @@ export function SettingsModal({
|
|||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: 2x2 grid nav */}
|
||||
<div className="grid grid-cols-2 gap-1 sm:hidden border-b border-line-soft bg-background p-2">
|
||||
{/* Mobile nav */}
|
||||
<SidebarNav layout="grid" className="sm:hidden border-b border-line-soft bg-background p-2">
|
||||
{visibleSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<button
|
||||
<SidebarNavItem
|
||||
compact
|
||||
key={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 ${
|
||||
activeSection === section.id
|
||||
? 'bg-accent text-background'
|
||||
: 'text-foreground hover:bg-accent-wash hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{section.label}
|
||||
</button>
|
||||
active={activeSection === section.id}
|
||||
icon={<Icon className="w-3.5 h-3.5" />}
|
||||
label={section.label}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SidebarNav>
|
||||
|
||||
<div className="flex flex-row h-[490px]">
|
||||
{/* Desktop: vertical sidebar */}
|
||||
<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) => {
|
||||
const Icon = section.icon;
|
||||
const active = activeSection === section.id;
|
||||
return (
|
||||
<button
|
||||
<SidebarNavItem
|
||||
key={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
|
||||
? 'bg-accent text-background'
|
||||
: 'text-foreground hover:bg-surface hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{section.label}
|
||||
</button>
|
||||
active={active}
|
||||
icon={<Icon className="w-4 h-4" />}
|
||||
label={section.label}
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SidebarNav>
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
|
|
@ -1166,30 +1159,16 @@ export function SettingsModal({
|
|||
{/* Admin Section */}
|
||||
{activeSection === 'admin' && isAdmin && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Admin tab"
|
||||
className={`${segmentedGroupClass} grid-cols-2`}
|
||||
>
|
||||
{([
|
||||
{ id: 'providers', label: 'Shared providers' },
|
||||
{ id: 'features', label: 'Site features' },
|
||||
] as { id: AdminSubTab; label: string }[]).map((tab) => {
|
||||
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>
|
||||
<SegmentedControl
|
||||
value={adminSubTab}
|
||||
options={[
|
||||
{ value: 'providers', label: 'Shared providers' },
|
||||
{ value: 'features', label: 'Site features' },
|
||||
]}
|
||||
onChange={setAdminSubTab}
|
||||
ariaLabel="Admin tab"
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
{adminSubTab === 'providers' && <AdminProvidersPanel />}
|
||||
{adminSubTab === 'features' && <AdminFeaturesPanel />}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ 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 } from '@/components/ui';
|
||||
import { IconButton, MenuItemClass, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui';
|
||||
|
||||
interface FinderSidebarProps {
|
||||
filter: SidebarFilter;
|
||||
|
|
@ -31,60 +31,6 @@ interface FinderSidebarProps {
|
|||
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 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({
|
||||
folder,
|
||||
active,
|
||||
|
|
@ -123,7 +69,8 @@ function FolderRow({
|
|||
ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
|
||||
className="group/folder relative"
|
||||
>
|
||||
<SidebarRow
|
||||
<SidebarNavItem
|
||||
compact
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
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({
|
||||
filter,
|
||||
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"
|
||||
>
|
||||
<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 && (
|
||||
<div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{topSlot}
|
||||
</div>
|
||||
)}
|
||||
<SectionHeader isFirst={!!topSlot}>Library</SectionHeader>
|
||||
<SidebarRow
|
||||
<SidebarNavGroup isFirst={!!topSlot}>Library</SidebarNavGroup>
|
||||
<SidebarNavItem
|
||||
compact
|
||||
active={filter === 'all'}
|
||||
onClick={() => {
|
||||
onFilterChange('all');
|
||||
|
|
@ -227,7 +153,8 @@ export function FinderSidebar({
|
|||
label="All Documents"
|
||||
count={counts.all}
|
||||
/>
|
||||
<SidebarRow
|
||||
<SidebarNavItem
|
||||
compact
|
||||
active={filter === 'recents'}
|
||||
onClick={() => {
|
||||
onFilterChange('recents');
|
||||
|
|
@ -237,8 +164,9 @@ export function FinderSidebar({
|
|||
label="Recently Opened"
|
||||
/>
|
||||
|
||||
<SectionHeader>Kinds</SectionHeader>
|
||||
<SidebarRow
|
||||
<SidebarNavGroup>Kinds</SidebarNavGroup>
|
||||
<SidebarNavItem
|
||||
compact
|
||||
active={filter === 'pdf'}
|
||||
onClick={() => {
|
||||
onFilterChange('pdf');
|
||||
|
|
@ -248,7 +176,8 @@ export function FinderSidebar({
|
|||
label="PDF"
|
||||
count={counts.pdf}
|
||||
/>
|
||||
<SidebarRow
|
||||
<SidebarNavItem
|
||||
compact
|
||||
active={filter === 'epub'}
|
||||
onClick={() => {
|
||||
onFilterChange('epub');
|
||||
|
|
@ -258,7 +187,8 @@ export function FinderSidebar({
|
|||
label="EPUB"
|
||||
count={counts.epub}
|
||||
/>
|
||||
<SidebarRow
|
||||
<SidebarNavItem
|
||||
compact
|
||||
active={filter === 'html'}
|
||||
onClick={() => {
|
||||
onFilterChange('html');
|
||||
|
|
@ -269,8 +199,8 @@ export function FinderSidebar({
|
|||
count={counts.html}
|
||||
/>
|
||||
|
||||
<SectionHeader
|
||||
rightSlot={(
|
||||
<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}
|
||||
|
|
@ -333,7 +263,7 @@ export function FinderSidebar({
|
|||
)}
|
||||
>
|
||||
Folders
|
||||
</SectionHeader>
|
||||
</SidebarNavGroup>
|
||||
{folders.length === 0 ? (
|
||||
<p className="px-2 py-1 text-[11px] text-soft">No folders yet</p>
|
||||
) : (
|
||||
|
|
@ -354,7 +284,7 @@ export function FinderSidebar({
|
|||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SidebarNav>
|
||||
</div>
|
||||
{bottomSlot && (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
HamburgerIcon,
|
||||
} from './finderIcons';
|
||||
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';
|
||||
|
||||
interface FinderToolbarProps {
|
||||
|
|
@ -151,19 +151,12 @@ export function FinderToolbar({
|
|||
<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-surface-sunken border border-line shadow-elev-2 p-1 focus:outline-none"
|
||||
>
|
||||
<ListboxOptions anchor="bottom end" className={listboxOptionsClass}>
|
||||
{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-surface-sunken text-accent' : 'text-foreground'
|
||||
} ${selected ? 'font-semibold' : ''}`
|
||||
}
|
||||
className={({ active, selected }) => listboxOptionClass(active || selected)}
|
||||
>
|
||||
{opt.label}
|
||||
</ListboxOption>
|
||||
|
|
@ -175,16 +168,13 @@ export function FinderToolbar({
|
|||
|
||||
<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]">
|
||||
<SearchIcon className="w-3.5 h-3.5 text-soft 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-soft"
|
||||
/>
|
||||
</div>
|
||||
<SearchField
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder="Search"
|
||||
className="hidden w-[160px] md:w-[200px] sm:flex"
|
||||
icon={<SearchIcon className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
|
||||
{rightSlot && (
|
||||
<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 { ZoomControl } from '@/components/documents/ZoomControl';
|
||||
import { UserMenu } from '@/components/auth/UserMenu';
|
||||
import { IconButton, MenuItemClass, ToolbarButton, menuPanelClass } from '@/components/ui';
|
||||
|
||||
interface DocumentHeaderMenuProps {
|
||||
zoomLevel: number;
|
||||
|
|
@ -47,45 +48,33 @@ export function DocumentHeaderMenu({
|
|||
max={maxZoom}
|
||||
/>
|
||||
{onOpenSegments && (
|
||||
<button
|
||||
<ToolbarButton
|
||||
onClick={onOpenSegments}
|
||||
className={`inline-flex items-center py-1 px-2 rounded-md border bg-surface text-xs transition duration-base ease-standard ${
|
||||
isSegmentsOpen
|
||||
? 'border-accent text-accent bg-surface-sunken'
|
||||
: 'border-line text-foreground hover:bg-accent-wash hover:text-accent'
|
||||
}`}
|
||||
active={isSegmentsOpen}
|
||||
aria-label={isSegmentsOpen ? 'Hide segments sidebar' : 'Open segments sidebar'}
|
||||
title={isSegmentsOpen ? 'Hide Segments' : 'Segments'}
|
||||
>
|
||||
<ListIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
|
||||
</button>
|
||||
</ToolbarButton>
|
||||
)}
|
||||
{showAudiobookExport && onOpenAudiobook && (
|
||||
<button
|
||||
<ToolbarButton
|
||||
onClick={onOpenAudiobook}
|
||||
className={`inline-flex items-center py-1 px-2 rounded-md border bg-surface text-xs transition duration-base ease-standard ${
|
||||
isAudiobookOpen
|
||||
? 'border-accent text-accent bg-surface-sunken'
|
||||
: 'border-line text-foreground hover:bg-accent-wash hover:text-accent'
|
||||
}`}
|
||||
active={isAudiobookOpen}
|
||||
aria-label={isAudiobookOpen ? 'Hide audiobook export' : 'Open audiobook export'}
|
||||
title={isAudiobookOpen ? 'Hide Export Audiobook' : 'Export Audiobook'}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
|
||||
</button>
|
||||
</ToolbarButton>
|
||||
)}
|
||||
<button
|
||||
<ToolbarButton
|
||||
onClick={onOpenSettings}
|
||||
className={`inline-flex items-center py-1 px-2 rounded-md border bg-surface text-xs transition duration-base ease-standard ${
|
||||
isSettingsOpen
|
||||
? 'border-accent text-accent bg-surface-sunken'
|
||||
: 'border-line text-foreground hover:bg-accent-wash hover:text-accent'
|
||||
}`}
|
||||
active={isSettingsOpen}
|
||||
aria-label={isSettingsOpen ? 'Hide settings' : 'Open settings'}
|
||||
title={isSettingsOpen ? 'Hide Settings' : 'Settings'}
|
||||
>
|
||||
<FileSettingsIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
|
||||
</button>
|
||||
</ToolbarButton>
|
||||
<UserMenu />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -95,7 +84,9 @@ export function DocumentHeaderMenu({
|
|||
<div className="sm:hidden flex items-center">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
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 */}
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs font-medium text-soft mb-2">Zoom / Padding</p>
|
||||
|
|
@ -138,8 +129,7 @@ export function DocumentHeaderMenu({
|
|||
{({ active }) => (
|
||||
<button
|
||||
onClick={onOpenSegments}
|
||||
className={`${active || isSegmentsOpen ? 'bg-surface-sunken text-accent' : 'text-foreground'
|
||||
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
className={MenuItemClass(active || isSegmentsOpen)}
|
||||
>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
{isSegmentsOpen ? 'Hide Segments' : 'Segments'}
|
||||
|
|
@ -152,8 +142,7 @@ export function DocumentHeaderMenu({
|
|||
{({ active }) => (
|
||||
<button
|
||||
onClick={onOpenAudiobook}
|
||||
className={`${active || isAudiobookOpen ? 'bg-surface-sunken text-accent' : 'text-foreground'
|
||||
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
className={MenuItemClass(active || isAudiobookOpen)}
|
||||
>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
{isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
|
||||
|
|
@ -165,8 +154,7 @@ export function DocumentHeaderMenu({
|
|||
{({ active }) => (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className={`${active || isSettingsOpen ? 'bg-surface-sunken text-accent' : 'text-foreground'
|
||||
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
className={MenuItemClass(active || isSettingsOpen)}
|
||||
>
|
||||
<FileSettingsIcon className="h-4 w-4" />
|
||||
{isSettingsOpen ? 'Hide Settings' : 'Settings'}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ import {
|
|||
clampSegmentPreloadSentenceLookahead,
|
||||
clampTtsSegmentMaxBlockLength,
|
||||
} from '@/types/config';
|
||||
import { Section, ToggleRow, CheckItem, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives';
|
||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
||||
import { Section, ToggleRow, CheckItem, SegmentedControl, buttonClass, rangeInputClass } from '@/components/ui';
|
||||
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
|
||||
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
|
||||
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
|
||||
|
|
@ -51,8 +50,6 @@ const viewTypeTextMapping = [
|
|||
{ 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 = {
|
||||
label: string;
|
||||
value: number;
|
||||
|
|
@ -87,7 +84,7 @@ function RangeSetting({
|
|||
step={step}
|
||||
value={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>
|
||||
</div>
|
||||
|
|
@ -163,27 +160,13 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
|||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">Page mode</label>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Page mode"
|
||||
className={`${segmentedGroupClass} grid-cols-3`}
|
||||
>
|
||||
{viewTypeTextMapping.map((view) => {
|
||||
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>
|
||||
<SegmentedControl
|
||||
value={selectedView.id as ViewType}
|
||||
options={viewTypeTextMapping.map((view) => ({ value: view.id as ViewType, label: view.name }))}
|
||||
onChange={(nextViewType) => updateConfigKey('viewType', nextViewType)}
|
||||
ariaLabel="Page mode"
|
||||
className="grid-cols-3"
|
||||
/>
|
||||
{selectedView.id === 'scroll' ? (
|
||||
<p className="text-xs text-warning">Scroll mode may be slower on large PDFs.</p>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@ export * from './input';
|
|||
export * from './menu';
|
||||
export * from './popover';
|
||||
export * from './range';
|
||||
export * from './search-field';
|
||||
export * from './section';
|
||||
export * from './select';
|
||||
export * from './segmented-control';
|
||||
export * from './sidebar';
|
||||
export * from './sidebar-nav';
|
||||
export * from './surface';
|
||||
export * from './switch';
|
||||
export * from './tokens';
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
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({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
|
||||
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}
|
||||
</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-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 { cn } from './cn';
|
||||
export { segmentedButtonClass, segmentedGroupClass } from './segmented-control';
|
||||
|
||||
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';
|
||||
|
|
@ -12,14 +13,6 @@ export const listboxOptionsClass =
|
|||
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');
|
||||
|
||||
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({
|
||||
value,
|
||||
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