From 4086f800664e590b9085ff0974c87a25b2cff87c Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 1 Jun 2026 13:40:47 -0600 Subject: [PATCH] refactor(ui): consolidate button, menu, popover, and range primitives for unified usage Remove legacy UI harness and dev/demo files. Replace scattered button, menu, popover, and range input utilities with shared, composable primitives: Button, ButtonLink, ButtonAnchor, MenuActionItem, MenuItemsSurface, PopoverSurface, PopoverTrigger, and RangeInput. Update all usages across app, admin, player, and document components to use these new primitives, eliminating duplicated class logic and improving consistency. Remove obsolete utility files and class exports. This change streamlines UI code, centralizes styling, and reduces maintenance overhead. --- src/app/(app)/dev/ui/page.tsx | 5 - src/app/(app)/epub/[id]/page.tsx | 18 +- src/app/(app)/html/[id]/page.tsx | 18 +- src/app/(app)/pdf/[id]/page.tsx | 27 +-- src/app/(public)/layout.tsx | 23 +-- src/app/(public)/page.tsx | 39 +--- src/app/(public)/privacy/page.tsx | 24 +-- src/components/AudiobookExportModal.tsx | 94 ++++----- src/components/CodeBlock.tsx | 14 +- src/components/ColorPicker.tsx | 13 +- src/components/CookieConsentBanner.tsx | 26 +-- src/components/ProgressCard.tsx | 14 +- src/components/SettingsModal.tsx | 37 ++-- src/components/admin/AdminFeaturesPanel.tsx | 21 +- src/components/admin/AdminProvidersPanel.tsx | 105 ++++------ src/components/auth/UserMenu.tsx | 10 +- src/components/dev/UiHarness.tsx | 188 ------------------ src/components/doclist/views/GalleryView.tsx | 17 +- .../doclist/window/FinderSidebar.tsx | 64 +++--- .../documents/DocumentHeaderMenu.tsx | 53 ++--- src/components/documents/DocumentSettings.tsx | 15 +- src/components/documents/ZoomControl.tsx | 26 +-- src/components/player/Navigator.tsx | 15 +- src/components/player/SpeedControl.tsx | 20 +- src/components/player/VoicesControlBase.tsx | 6 +- src/components/reader/SegmentsSidebar.tsx | 10 +- src/components/ui/button.tsx | 41 +++- src/components/ui/menu.tsx | 47 ++++- src/components/ui/popover.ts | 9 - src/components/ui/popover.tsx | 35 ++++ src/components/ui/{range.ts => range.tsx} | 12 +- src/components/ui/select.tsx | 21 +- 32 files changed, 398 insertions(+), 669 deletions(-) delete mode 100644 src/app/(app)/dev/ui/page.tsx delete mode 100644 src/components/dev/UiHarness.tsx delete mode 100644 src/components/ui/popover.ts create mode 100644 src/components/ui/popover.tsx rename src/components/ui/{range.ts => range.tsx} (71%) diff --git a/src/app/(app)/dev/ui/page.tsx b/src/app/(app)/dev/ui/page.tsx deleted file mode 100644 index 25c2f3c..0000000 --- a/src/app/(app)/dev/ui/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { UiHarness } from '@/components/dev/UiHarness'; - -export default function UiDevPage() { - return ; -} diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index b2df05d..4a25c2e 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useParams, useRouter } from "next/navigation"; -import Link from 'next/link'; import { useCallback, useEffect, useRef, useState } from 'react'; import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { EPUBViewer } from '@/components/views/EPUBViewer'; @@ -20,7 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; -import { buttonClass } from '@/components/ui'; +import { ButtonLink } from '@/components/ui'; import { useEpubDocument } from './useEpubDocument'; export default function EPUBPage() { @@ -172,15 +171,12 @@ export default function EPUBPage() { return (

{error}

- + Back to Documents - +
); } @@ -189,16 +185,12 @@ export default function EPUBPage() { <>
+ Documents - + } title={isLoading ? 'Loading…' : (currDocName || '')} right={ diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx index d5ec371..f0193a3 100644 --- a/src/app/(app)/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useParams, useRouter } from "next/navigation"; -import Link from 'next/link'; import { useCallback, useEffect, useRef, useState } from 'react'; import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { HTMLViewer } from '@/components/views/HTMLViewer'; @@ -18,7 +17,7 @@ import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; -import { buttonClass } from '@/components/ui'; +import { ButtonLink } from '@/components/ui'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import { useHtmlDocument } from './useHtmlDocument'; @@ -159,15 +158,12 @@ export default function HTMLPage() { return (

{error}

- + Back to Documents - +
); } @@ -176,16 +172,12 @@ export default function HTMLPage() { <>
+ Documents - + } title={isLoading ? 'Loading…' : (currDocName || '')} right={ diff --git a/src/app/(app)/pdf/[id]/page.tsx b/src/app/(app)/pdf/[id]/page.tsx index bd9f17b..0d1b3b9 100644 --- a/src/app/(app)/pdf/[id]/page.tsx +++ b/src/app/(app)/pdf/[id]/page.tsx @@ -2,7 +2,6 @@ import dynamic from 'next/dynamic'; import { useParams, useRouter } from 'next/navigation'; -import Link from 'next/link'; import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react'; import { useTTS } from '@/contexts/TTSContext'; import { DocumentSettings } from '@/components/documents/DocumentSettings'; @@ -20,7 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { LoadingSpinner } from '@/components/Spinner'; -import { buttonClass } from '@/components/ui'; +import { Button, ButtonLink } from '@/components/ui'; import { FORCE_REPARSE_CONFIRM_MESSAGE, FORCE_REPARSE_CONFIRM_TEXT, @@ -253,16 +252,12 @@ export default function PDFViewerPage() { return (

{error}

- + Back to Documents - +
); } @@ -343,13 +338,14 @@ export default function PDFViewerPage() { {!isLoading && parseUiState === 'failed' ? (
- +
) : null} @@ -382,17 +378,12 @@ export default function PDFViewerPage() { <>
+ Documents - + } title={isLoading ? 'Loading…' : (currDocName || '')} right={ diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx index 4b08f30..f850899 100644 --- a/src/app/(public)/layout.tsx +++ b/src/app/(public)/layout.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import Link from 'next/link'; import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc'; -import { buttonClass } from '@/components/ui'; +import { ButtonAnchor, ButtonLink } from '@/components/ui'; import './public.css'; export default async function PublicLayout({ children }: { children: ReactNode }) { @@ -42,12 +42,8 @@ export default async function PublicLayout({ children }: { children: ReactNode } GitHub
@@ -70,18 +66,11 @@ export default async function PublicLayout({ children }: { children: ReactNode }

{enableUserSignups ? ( - - Create account - + Create account ) : null} - + Star on GitHub - +
diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index 49ead03..069ca95 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -2,7 +2,7 @@ import type { CSSProperties } from 'react'; import type { Metadata } from 'next'; import Link from 'next/link'; import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc'; -import { buttonClass } from '@/components/ui'; +import { ButtonAnchor, ButtonLink } from '@/components/ui'; export const metadata: Metadata = { title: 'Open Source Read-Along Workspace', @@ -98,24 +98,13 @@ export default async function LandingPage() {

- - Open the reader - + Open the reader {enableUserSignups ? ( - - Create account - + Create account ) : ( - - Sign in - + Sign in )} - - Read the docs → - + Read the docs →
@@ -316,22 +305,12 @@ export default async function LandingPage() { external compute worker. Every piece is yours to host.

diff --git a/src/app/(public)/privacy/page.tsx b/src/app/(public)/privacy/page.tsx index 30818b5..96ac56b 100644 --- a/src/app/(public)/privacy/page.tsx +++ b/src/app/(public)/privacy/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import { headers } from 'next/headers'; -import { buttonClass } from '@/components/ui'; +import { ButtonAnchor, ButtonLink } from '@/components/ui'; export const metadata: Metadata = { title: 'Privacy & Data Usage', @@ -181,25 +181,13 @@ export default async function PrivacyPage() { want full infrastructure control.

- + Open an Issue - - + + Self-Hosting Guide - - - Back to landing - + + Back to landing
diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index ca378e7..1f9e7e3 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -1,7 +1,7 @@ 'use client'; import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react'; -import { Transition, Listbox, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'; +import { Transition, Listbox, Menu, MenuButton } from '@headlessui/react'; import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { ProgressPopup } from '@/components/ProgressPopup'; import { ProgressCard } from '@/components/ProgressCard'; @@ -13,7 +13,7 @@ import { VoicesControlBase } from '@/components/player/VoicesControlBase'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; -import { Button, Card, IconButton, MenuItemClass, SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn, menuPanelClass, rangeInputClass } from '@/components/ui'; +import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn } from '@/components/ui'; import { getAudiobookStatus, deleteAudiobookChapter, @@ -621,14 +621,12 @@ export function AudiobookExportModal({ {formatSpeed(nativeSpeed)}x - setNativeSpeed(parseFloat(e.target.value))} - className={rangeInputClass} />
0.5x @@ -645,14 +643,12 @@ export function AudiobookExportModal({ {formatSpeed(postSpeed)}x
- setPostSpeed(parseFloat(e.target.value))} - className={rangeInputClass} />
0.5x @@ -794,68 +790,48 @@ export function AudiobookExportModal({ leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - {chapter.status === 'completed' && ( <> - - {({ active }) => ( - - )} - - - {({ active }) => ( - - )} - + setPendingDeleteChapter(chapter)} + title="Delete this chapter" + > + + Delete + + handleDownloadChapter(chapter)}> + + Download + )} {regeneratingChapter === chapter.index && ( - - {({ active }) => ( - - )} - + + + Cancel + )} {onRegenerateChapter && !isGenerating && ( - - {({ active, disabled }) => ( - - )} - + handleRegenerateChapter(chapter)} + title="Regenerate this chapter" + > + + {regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'} + )} - + {/* end of menu items */} diff --git a/src/components/CodeBlock.tsx b/src/components/CodeBlock.tsx index f2d327c..3217389 100644 --- a/src/components/CodeBlock.tsx +++ b/src/components/CodeBlock.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { CopyIcon, CheckIcon } from '@/components/icons/Icons'; -import { buttonClass } from '@/components/ui'; +import { Button } from '@/components/ui'; export function CodeBlock({ children }: { children: string }) { const [copied, setCopied] = useState(false); @@ -19,17 +19,15 @@ export function CodeBlock({ children }: { children: string }) { font-mono text-foreground border border-line"> {children} - +
); } diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index e203595..374ec34 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,10 +1,10 @@ 'use client'; import { useRef, useState, useEffect, useCallback } from 'react'; -import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; +import { Popover, PopoverButton } from '@headlessui/react'; import { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext'; import { PaletteIcon } from '@/components/icons/Icons'; -import { IconButton, Input, cn, popoverPanelClass } from '@/components/ui'; +import { IconButton, Input, PopoverSurface } from '@/components/ui'; /** * Curated swatch palettes per color role, sourced from existing themes @@ -89,13 +89,10 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps) /> - {/* Label */}
@@ -173,7 +170,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps) className="flex-1 font-mono" />
-
+ ); } diff --git a/src/components/CookieConsentBanner.tsx b/src/components/CookieConsentBanner.tsx index 3e1a278..de748e0 100644 --- a/src/components/CookieConsentBanner.tsx +++ b/src/components/CookieConsentBanner.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { getConsentState, setConsentState } from '@/lib/client/analytics'; import { Transition } from '@headlessui/react'; -import { buttonClass } from '@/components/ui'; +import { Button } from '@/components/ui'; export function CookieConsentBanner() { const [show, setShow] = useState(false); @@ -55,26 +55,22 @@ export function CookieConsentBanner() {
- - +
diff --git a/src/components/ProgressCard.tsx b/src/components/ProgressCard.tsx index 380a45a..f8341d0 100644 --- a/src/components/ProgressCard.tsx +++ b/src/components/ProgressCard.tsx @@ -1,4 +1,4 @@ -import { buttonClass } from '@/components/ui'; +import { Button } from '@/components/ui'; interface ProgressCardProps { progress: number; @@ -52,17 +52,15 @@ export function ProgressCard({ )} - + {/* Progress bar */} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 7d71964..3114e6b 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -4,9 +4,6 @@ import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; import { Transition, Listbox, - ListboxButton, - ListboxOptions, - ListboxOption, } from '@headlessui/react'; import Link from 'next/link'; import { useTheme } from '@/contexts/ThemeContext'; @@ -56,9 +53,9 @@ import { ModalFrame, ModalTitle, inputClass, - listboxButtonClass, - listboxOptionClass, - listboxOptionsClass, + SharedListboxButton, + SharedListboxOption, + SharedListboxOptions, } from '@/components/ui'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -692,28 +689,26 @@ export function SettingsModal({ setCustomModelInput(''); }} > - + {selectedProviderOption?.name || 'Select Provider'} - + - {ttsProviders.map((provider) => ( - listboxOptionClass(active)} value={provider} > {({ selected }) => ( @@ -728,9 +723,9 @@ export function SettingsModal({ )} )} - + ))} - + )} @@ -797,7 +792,7 @@ export function SettingsModal({ } }} > - + {selectedModel ? ( @@ -815,21 +810,19 @@ export function SettingsModal({ - + - {ttsModels.map((model) => ( - listboxOptionClass(active)} value={model} > {({ selected }) => ( @@ -849,9 +842,9 @@ export function SettingsModal({ )} )} - + ))} - + diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx index af55edc..162e4a5 100644 --- a/src/components/admin/AdminFeaturesPanel.tsx +++ b/src/components/admin/AdminFeaturesPanel.tsx @@ -1,7 +1,7 @@ 'use client'; import { Fragment, useEffect, useMemo, useState } from 'react'; -import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react'; +import { Listbox, Transition } from '@headlessui/react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; @@ -10,9 +10,9 @@ import { Section, ToggleRow, inputClass, - listboxButtonClass, - listboxOptionClass, - listboxOptionsClass, + SharedListboxButton, + SharedListboxOption, + SharedListboxOptions, Button, } from '@/components/ui'; import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; @@ -205,24 +205,23 @@ export function AdminFeaturesPanel() { {providerOptions.length > 0 ? ( - + {selectedProviderOption?.name ?? 'Select provider'} - + - + {providerOptions.map((opt) => ( - listboxOptionClass(active)} > {({ selected }) => ( <> @@ -236,9 +235,9 @@ export function AdminFeaturesPanel() { )} )} - + ))} - + ) : ( diff --git a/src/components/admin/AdminProvidersPanel.tsx b/src/components/admin/AdminProvidersPanel.tsx index 2f61ba3..8960fe9 100644 --- a/src/components/admin/AdminProvidersPanel.tsx +++ b/src/components/admin/AdminProvidersPanel.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'; +import { Listbox, Menu, MenuButton, Transition } from '@headlessui/react'; import { Fragment } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; @@ -14,15 +14,14 @@ import { Section, ToggleRow, inputClass, - listboxButtonClass, - listboxOptionClass, - listboxOptionsClass, + SharedListboxButton, + SharedListboxOption, + SharedListboxOptions, Button, IconButton, Input, - MenuItemClass, - cn, - menuPanelClass, + MenuItemsSurface, + MenuActionItem, } from '@/components/ui'; type ProviderType = TtsProviderId; @@ -426,24 +425,23 @@ export function AdminProvidersPanel() { setCustomModelInput(''); }} > - + {selectedProviderType.label} - + - + {PROVIDER_TYPE_OPTIONS.map((opt) => ( - listboxOptionClass(active)} > {({ selected }) => ( <> @@ -457,9 +455,9 @@ export function AdminProvidersPanel() { )} )} - + ))} - + @@ -485,26 +483,25 @@ export function AdminProvidersPanel() { setCustomModelInput(''); }} > - + {selectedModelDefinition?.name ?? 'Select model'} - + - + {modelDefinitions.map((model) => ( - listboxOptionClass(active)} > {({ selected }) => ( <> @@ -523,9 +520,9 @@ export function AdminProvidersPanel() { )} )} - + ))} - + {supportsCustomModel && selectedModelId === 'custom' && ( @@ -663,56 +660,26 @@ export function AdminProvidersPanel() { leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - - - {({ active }) => ( - - )} - - - {({ active, disabled }) => ( - - )} - - - {({ active }) => ( - - )} - - - {({ active }) => ( - - )} - - + startEdit(p)}> + Edit + + setDefault(p.slug)} + disabled={!p.enabled || defaultProviderSlug === p.slug} + > + Set as default + + toggleEnabled(p)}> + {p.enabled ? 'Disable' : 'Enable'} + + remove(p.id)}> + Delete + + diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index c459eb1..e780d2a 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -7,7 +7,7 @@ import { useAuthSession } from '@/hooks/useAuthSession'; import { getAuthClient } from '@/lib/client/auth-client'; import { useRouter } from 'next/navigation'; import { UserIcon } from '@/components/icons/Icons'; -import { IconButton, SidebarNavItem, SidebarNavLink, buttonClass } from '@/components/ui'; +import { ButtonLink, IconButton, SidebarNavItem, SidebarNavLink } from '@/components/ui'; type UserMenuVariant = 'toolbar' | 'sidebar'; @@ -57,13 +57,13 @@ export function UserMenu({ return (
- + Connect - + {enableUserSignups && ( - + Create account - + )}
); diff --git a/src/components/dev/UiHarness.tsx b/src/components/dev/UiHarness.tsx deleted file mode 100644 index 11b4764..0000000 --- a/src/components/dev/UiHarness.tsx +++ /dev/null @@ -1,188 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { THEMES, useTheme } from '@/contexts/ThemeContext'; -import { - Badge, - Button, - Card, - CheckItem, - Divider, - Field, - IconButton, - Input, - Panel, - Section, - Select, - Sidebar, - Surface, - Switch, - Textarea, - ToggleRow, - Toolbar, - ToolbarButton, - ToolbarGroup, - ToolbarSegment, - buttonClass, - dropzoneSurfaceClass, - popoverPanelClass, - rangeInputClass, -} from '@/components/ui'; - -const THEME_OPTIONS = THEMES.filter((theme) => theme !== 'custom'); - -export function UiHarness() { - const { theme, setTheme } = useTheme(); - const [enabled, setEnabled] = useState(true); - const [checked, setChecked] = useState(false); - const [selectValue, setSelectValue] = useState('library'); - - return ( -
-
-
-
- Authenticated dev surface -

UI system harness

-

- Review shared primitives, state tokens, and theme behavior from the authenticated app shell. -

-
-
- - -
-
- -
-
- {THEME_OPTIONS.map((option) => ( - - ))} -
-
- -
- -
- - - - - - - - × -
- - - -
- - - - -