diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 62593bc..47026a2 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -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 */} -
+ {/* Mobile nav */} + {visibleSections.map((section) => { const Icon = section.icon; return ( - + active={activeSection === section.id} + icon={} + label={section.label} + /> ); })} -
+
{/* Desktop: vertical sidebar */} {/* Content */} @@ -1166,30 +1159,16 @@ export function SettingsModal({ {/* Admin Section */} {activeSection === 'admin' && isAdmin && (
-
- {([ - { id: 'providers', label: 'Shared providers' }, - { id: 'features', label: 'Site features' }, - ] as { id: AdminSubTab; label: string }[]).map((tab) => { - const active = adminSubTab === tab.id; - return ( - - ); - })} -
+ {adminSubTab === 'providers' && } {adminSubTab === 'features' && }
diff --git a/src/components/doclist/window/FinderSidebar.tsx b/src/components/doclist/window/FinderSidebar.tsx index ae95918..fc6dc56 100644 --- a/src/components/doclist/window/FinderSidebar.tsx +++ b/src/components/doclist/window/FinderSidebar.tsx @@ -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 ( - - ); -} - function FolderRow({ folder, active, @@ -123,7 +69,8 @@ function FolderRow({ ref={dropRef as unknown as React.RefObject} className="group/folder relative" > - } @@ -150,28 +97,6 @@ function FolderRow({ ); } -function SectionHeader({ - children, - isFirst, - rightSlot, -}: { - children: ReactNode; - isFirst?: boolean; - rightSlot?: ReactNode; -}) { - return ( -
- {children} - {rightSlot && {rightSlot}} -
- ); -} - 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" >
-
+ {topSlot && (
e.stopPropagation()}> {topSlot}
)} - Library - Library + { onFilterChange('all'); @@ -227,7 +153,8 @@ export function FinderSidebar({ label="All Documents" count={counts.all} /> - { onFilterChange('recents'); @@ -237,8 +164,9 @@ export function FinderSidebar({ label="Recently Opened" /> - Kinds - Kinds + { onFilterChange('pdf'); @@ -248,7 +176,8 @@ export function FinderSidebar({ label="PDF" count={counts.pdf} /> - { onFilterChange('epub'); @@ -258,7 +187,8 @@ export function FinderSidebar({ label="EPUB" count={counts.epub} /> - { onFilterChange('html'); @@ -269,8 +199,8 @@ export function FinderSidebar({ count={counts.html} /> - Folders - + {folders.length === 0 ? (

No folders yet

) : ( @@ -354,7 +284,7 @@ export function FinderSidebar({ /> )) )} -
+
{bottomSlot && (
{currentSort.label} - + {SORT_OPTIONS.map((opt) => ( - `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} @@ -175,16 +168,13 @@ export function FinderToolbar({
-
- - onQueryChange(e.target.value)} - placeholder="Search" - className="flex-1 min-w-0 bg-transparent outline-none text-xs text-foreground placeholder:text-soft" - /> -
+ onQueryChange(e.target.value)} + placeholder="Search" + className="hidden w-[160px] md:w-[200px] sm:flex" + icon={} + /> {rightSlot && (
diff --git a/src/components/documents/DocumentHeaderMenu.tsx b/src/components/documents/DocumentHeaderMenu.tsx index 09a4221..c6a671f 100644 --- a/src/components/documents/DocumentHeaderMenu.tsx +++ b/src/components/documents/DocumentHeaderMenu.tsx @@ -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 && ( - + )} {showAudiobookExport && onOpenAudiobook && ( - + )} - +
); @@ -95,7 +84,9 @@ export function DocumentHeaderMenu({
@@ -109,7 +100,7 @@ export function DocumentHeaderMenu({ leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - + {/* Zoom Controls Section */}

Zoom / Padding

@@ -138,8 +129,7 @@ export function DocumentHeaderMenu({ {({ active }) => (
@@ -163,27 +160,13 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { >
-
- {viewTypeTextMapping.map((view) => { - const active = selectedView.id === view.id; - return ( - - ); - })} -
+ ({ value: view.id as ViewType, label: view.name }))} + onChange={(nextViewType) => updateConfigKey('viewType', nextViewType)} + ariaLabel="Page mode" + className="grid-cols-3" + /> {selectedView.id === 'scroll' ? (

Scroll mode may be slower on large PDFs.

) : null} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index da14cf7..7dddd6a 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -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'; diff --git a/src/components/ui/menu.tsx b/src/components/ui/menu.tsx index cf3f2dd..26ae858 100644 --- a/src/components/ui/menu.tsx +++ b/src/components/ui/menu.tsx @@ -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 & { children: ReactNode }) { return ( -
+
{children}
); diff --git a/src/components/ui/range.ts b/src/components/ui/range.ts index 37818f0..a7b75e7 100644 --- a/src/components/ui/range.ts +++ b/src/components/ui/range.ts @@ -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); +} diff --git a/src/components/ui/search-field.tsx b/src/components/ui/search-field.tsx new file mode 100644 index 0000000..be3b353 --- /dev/null +++ b/src/components/ui/search-field.tsx @@ -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, 'type'> & { + icon?: ReactNode; + inputClassName?: string; +}) { + return ( + + ); +} diff --git a/src/components/ui/segmented-control.tsx b/src/components/ui/segmented-control.tsx new file mode 100644 index 0000000..fbfcba1 --- /dev/null +++ b/src/components/ui/segmented-control.tsx @@ -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({ + value, + options, + onChange, + ariaLabel, + className, +}: { + value: T; + options: Array<{ value: T; label: ReactNode }>; + onChange: (value: T) => void; + ariaLabel: string; + className?: string; +}) { + return ( +
+ {options.map((option) => { + const active = value === option.value; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index 39d422a..7fd1826 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -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, diff --git a/src/components/ui/sidebar-nav.tsx b/src/components/ui/sidebar-nav.tsx new file mode 100644 index 0000000..41b83ed --- /dev/null +++ b/src/components/ui/sidebar-nav.tsx @@ -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 & { + children: ReactNode; + layout?: 'stack' | 'grid'; +}) { + return ( +
+ {children} +
+ ); +} + +export function SidebarNavGroup({ + children, + action, + className, + isFirst = false, + ...props +}: HTMLAttributes & { + children: ReactNode; + action?: ReactNode; + isFirst?: boolean; +}) { + return ( +
+ {children} + {action ? {action} : null} +
+ ); +} + +export function SidebarNavItem({ + active = false, + icon, + label, + count, + countClassName, + trailing, + isDropTarget = false, + className, + compact = false, + type = 'button', + children, + ...props +}: ButtonHTMLAttributes & { + active?: boolean; + icon?: ReactNode; + label?: ReactNode; + count?: number; + countClassName?: string; + trailing?: ReactNode; + isDropTarget?: boolean; + compact?: boolean; +}) { + return ( + + ); +}