From b4f30cf07200cd3c133a18c52e1998daf0676be9 Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 1 Jun 2026 02:41:48 -0600 Subject: [PATCH] phase 7: refactor app surfaces to ui layer --- src/app/(app)/layout.tsx | 7 +- src/app/(app)/signin/page.tsx | 41 ++++---- src/app/(app)/signup/page.tsx | 53 +++++----- src/components/ConfirmDialog.tsx | 28 ++---- src/components/Header.tsx | 17 +--- src/components/PrivacyModal.tsx | 21 ++-- src/components/auth/AuthLoader.tsx | 10 +- src/components/auth/ClaimDataModal.tsx | 20 ++-- src/components/doclist/CreateFolderDialog.tsx | 9 +- src/components/doclist/views/GalleryView.tsx | 2 +- .../doclist/window/FinderSidebar.tsx | 15 +-- .../doclist/window/FinderToolbar.tsx | 74 +++++--------- .../doclist/window/FinderWindow.tsx | 84 +++------------- .../documents/DexieMigrationModal.tsx | 19 ++-- .../documents/DocumentSelectionModal.tsx | 20 ++-- src/components/documents/DocumentUploader.tsx | 29 ++---- src/components/documents/ZoomControl.tsx | 2 +- src/components/layout/AppHeader.tsx | 30 ++++++ src/components/layout/AppShell.tsx | 14 +++ src/components/layout/LibraryFrame.tsx | 99 +++++++++++++++++++ src/components/layout/index.ts | 3 + src/components/player/Navigator.tsx | 24 ++--- .../player/RateLimitPauseButton.tsx | 7 +- src/components/player/SpeedControl.tsx | 15 +-- src/components/player/TTSPlayer.tsx | 17 ++-- src/components/ui/dropzone.ts | 40 ++++++++ src/components/ui/icon-button.tsx | 18 ++-- src/components/ui/index.ts | 3 + src/components/ui/input.tsx | 10 +- src/components/ui/popover.ts | 9 ++ src/components/ui/range.ts | 9 ++ src/components/ui/toolbar.tsx | 6 +- 32 files changed, 403 insertions(+), 352 deletions(-) create mode 100644 src/components/layout/AppHeader.tsx create mode 100644 src/components/layout/AppShell.tsx create mode 100644 src/components/layout/LibraryFrame.tsx create mode 100644 src/components/layout/index.ts create mode 100644 src/components/ui/dropzone.ts create mode 100644 src/components/ui/popover.ts create mode 100644 src/components/ui/range.ts diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index dcf5127..c048fbf 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { Toaster } from 'react-hot-toast'; import { Providers } from '@/app/providers'; +import { AppMain, AppShell } from '@/components/layout'; import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; export const dynamic = 'force-dynamic'; @@ -32,9 +33,9 @@ export default function AppLayout({ children }: { children: ReactNode }) { allowAnonymousAuthSessions={allowAnonymousAuthSessions} githubAuthEnabled={githubAuthEnabled} > -
-
{children}
-
+ + {children} + void }) { const searchParams = useSearchParams(); @@ -120,7 +119,7 @@ function SignInContent() { -
+

{sessionExpired ? 'Session Expired' : 'Connect Account'}

@@ -141,36 +140,32 @@ function SignInContent() { {error && (
-

{error}

+

{error}

)}
{/* Email */} -
- + { setEmail(e.target.value); setError(null); }} placeholder="me@example.com" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1 - focus:outline-none focus:ring-2 focus:ring-accent-line" + controlSize="lg" /> -
+ {/* Password */} -
- + { setPassword(e.target.value); setError(null); }} placeholder="Password" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1 - focus:outline-none focus:ring-2 focus:ring-accent-line" + controlSize="lg" /> -
+ {/* Remember Me */}
-
+ ); } diff --git a/src/app/(app)/signup/page.tsx b/src/app/(app)/signup/page.tsx index 9dde55e..f2b1ad8 100644 --- a/src/app/(app)/signup/page.tsx +++ b/src/app/(app)/signup/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState } from 'react'; -import { Button, Input } from '@headlessui/react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { getAuthClient } from '@/lib/client/auth-client'; @@ -9,7 +8,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { showPrivacyModal } from '@/components/PrivacyModal'; import { LoadingSpinner } from '@/components/Spinner'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button, Field, IconButton, Input, Surface } from '@/components/ui'; import toast from 'react-hot-toast'; export default function SignUpPage() { @@ -105,7 +104,7 @@ export default function SignUpPage() { if (!enableUserSignups) { return (
-
+

Sign-ups unavailable

New account sign-ups are currently disabled by the site administrator. @@ -118,7 +117,7 @@ export default function SignUpPage() {

-
+ ); } @@ -129,53 +128,45 @@ export default function SignUpPage() { return (
-
+

Sign Up

Create your account to get started

{error && (
-

{error}

+

{error}

)}
{/* Email */} -
- + setEmail(e.target.value)} placeholder="me@example.com" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1 - focus:outline-none focus:ring-2 focus:ring-accent-line" + controlSize="lg" /> -
+ {/* Password */} -
- +
setPassword(e.target.value)} placeholder="Password" - className="w-full rounded-lg bg-background py-2 px-3 pr-10 text-foreground shadow-elev-1 - focus:outline-none focus:ring-2 focus:ring-accent-line" + controlSize="lg" + className="pr-10" /> - +
{/* Password Strength */} @@ -209,32 +200,32 @@ export default function SignUpPage() {
)} -
+ {/* Confirm Password */} -
- + setPasswordConfirmation(e.target.value)} placeholder="Confirm Password" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1 - focus:outline-none focus:ring-2 focus:ring-accent-line" + controlSize="lg" /> {passwordConfirmation && password && (

{password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'}

)} -
+ {/* Sign Up Button */} @@ -258,7 +249,7 @@ export default function SignUpPage() {

- + ); } diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 40da54b..44213c2 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -1,6 +1,6 @@ import { Fragment, KeyboardEvent } from 'react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button, dialogPanelStyles } from '@/components/ui'; interface ConfirmDialogProps { isOpen: boolean; @@ -62,7 +62,7 @@ export function ConfirmDialog({ leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - +
- - +
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index a89ece1..e6394ef 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,3 +1,4 @@ +import { AppHeader } from '@/components/layout'; import { ReactNode } from "react"; export function Header({ @@ -9,19 +10,5 @@ export function Header({ title?: ReactNode; right?: ReactNode; }) { - return ( -
-
-
- {left} - {typeof title === 'string' ? ( -

{title}

- ) : ( - title - )} -
-
{right}
-
-
- ); + return ; } diff --git a/src/components/PrivacyModal.tsx b/src/components/PrivacyModal.tsx index 43e4073..0c63c61 100644 --- a/src/components/PrivacyModal.tsx +++ b/src/components/PrivacyModal.tsx @@ -7,9 +7,9 @@ import { DialogTitle, Transition, TransitionChild, - Button, } from '@headlessui/react'; import { updateAppConfig } from '@/lib/client/dexie'; +import { Button, dialogPanelStyles } from '@/components/ui'; interface PrivacyModalProps { isOpen: boolean; @@ -102,7 +102,7 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps) leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - + + ) : (

diff --git a/src/components/auth/ClaimDataModal.tsx b/src/components/auth/ClaimDataModal.tsx index 4f6f6cf..fbfa02f 100644 --- a/src/components/auth/ClaimDataModal.tsx +++ b/src/components/auth/ClaimDataModal.tsx @@ -7,10 +7,10 @@ import { DialogTitle, Transition, TransitionChild, - Button, } from '@headlessui/react'; import { useRouter } from 'next/navigation'; import toast from 'react-hot-toast'; +import { Button, dialogPanelStyles } from '@/components/ui'; export type ClaimableCounts = { documents: number; @@ -99,7 +99,7 @@ export default function ClaimDataModal({ leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - + diff --git a/src/components/doclist/CreateFolderDialog.tsx b/src/components/doclist/CreateFolderDialog.tsx index c66f7d1..9787811 100644 --- a/src/components/doclist/CreateFolderDialog.tsx +++ b/src/components/doclist/CreateFolderDialog.tsx @@ -1,5 +1,6 @@ import { Fragment, KeyboardEvent } from 'react'; -import { Dialog, DialogPanel, DialogTitle, Input, Transition, TransitionChild } from '@headlessui/react'; +import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; +import { dialogPanelStyles, Input } from '@/components/ui'; interface CreateFolderDialogProps { isOpen: boolean; @@ -42,7 +43,7 @@ export function CreateFolderDialog({ leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - + Create New Folder @@ -53,7 +54,7 @@ export function CreateFolderDialog({ onChange={(e) => onFolderNameChange(e.target.value)} onKeyDown={onKeyDown} placeholder="Enter folder name" - className="w-full rounded-lg bg-surface-sunken py-2 px-3 text-foreground shadow-elev-1 focus:outline-none focus:ring-1 focus:ring-accent-line" + controlSize="lg" autoFocus />

Press Enter to create or Escape to cancel

@@ -65,4 +66,4 @@ export function CreateFolderDialog({ ); -} \ No newline at end of file +} diff --git a/src/components/doclist/views/GalleryView.tsx b/src/components/doclist/views/GalleryView.tsx index 8077973..2941072 100644 --- a/src/components/doclist/views/GalleryView.tsx +++ b/src/components/doclist/views/GalleryView.tsx @@ -7,7 +7,7 @@ import type { DocumentListDocument } from '@/types/documents'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { formatDocumentSize } from '@/components/doclist/formatSize'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { buttonClass } from '@/components/ui'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; diff --git a/src/components/doclist/window/FinderSidebar.tsx b/src/components/doclist/window/FinderSidebar.tsx index e2f3614..ae95918 100644 --- a/src/components/doclist/window/FinderSidebar.tsx +++ b/src/components/doclist/window/FinderSidebar.tsx @@ -7,6 +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'; interface FinderSidebarProps { filter: SidebarFilter; @@ -204,9 +205,9 @@ export function FinderSidebar({ }; return ( - + ); } diff --git a/src/components/doclist/window/FinderToolbar.tsx b/src/components/doclist/window/FinderToolbar.tsx index 85fee30..6052ab2 100644 --- a/src/components/doclist/window/FinderToolbar.tsx +++ b/src/components/doclist/window/FinderToolbar.tsx @@ -10,6 +10,7 @@ import { HamburgerIcon, } from './finderIcons'; import { ChevronUpDownIcon } from '@/components/icons/Icons'; +import { Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment, toolbarButtonStyles } from '@/components/ui'; import type { ReactNode } from 'react'; interface FinderToolbarProps { @@ -52,22 +53,6 @@ const ICON_SIZES: Array<{ value: IconSize; label: string }> = [ { value: 'xl', label: 'XL' }, ]; -// Match SettingsModal / UserMenu trigger sizing exactly so all bar buttons share one rhythm. -const TOOLBAR_BTN = - 'inline-flex items-center py-1 px-2 rounded-md border bg-surface text-xs transition duration-base ease-standard'; -const TOOLBAR_BTN_INACTIVE = - 'border-line text-foreground hover:text-accent hover:border-accent-line hover:bg-accent-wash'; -const TOOLBAR_BTN_ACTIVE = 'border-accent-line bg-surface-sunken text-accent'; - -// Pill-grouped segmented control. Outer pill carries the border; inner segments are -// borderless and rely on bg/text color to show active/hover. Sized so the whole pill -// matches the height of a standalone TOOLBAR_BTN. -const PILL = 'inline-flex items-center rounded-md border border-line bg-surface p-0.5 gap-0.5 shrink-0'; -const PILL_SEGMENT = - 'inline-flex items-center justify-center rounded-sm text-xs transition-colors duration-base ease-standard'; -const PILL_SEGMENT_INACTIVE = 'text-soft hover:bg-accent-wash hover:text-accent'; -const PILL_SEGMENT_ACTIVE = 'bg-surface-sunken text-accent'; - export function FinderToolbar({ viewMode, onViewModeChange, @@ -89,26 +74,25 @@ export function FinderToolbar({ const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc; return ( -
-
+ {leftSlot && (
{leftSlot}
)} - + -
+ {VIEW_BUTTONS.map(({ value, label, Icon }) => { const active = viewMode === value; const isIconsToggle = value === 'icons'; @@ -117,65 +101,52 @@ export function FinderToolbar({ key={value} className={isIconsToggle ? 'relative group/icons inline-flex items-center' : 'inline-flex items-center'} > - + {isIconsToggle && viewMode === 'icons' && (
-
+ {ICON_SIZES.map(({ value: sizeValue, label: sizeLabel }) => { const sizeActive = iconSize === sizeValue; return ( - + ); })} -
+
)}
); })} -
+ {showSortControls && (
- + {currentSort.label} @@ -220,7 +191,6 @@ export function FinderToolbar({ {rightSlot}
)} -
- + ); } diff --git a/src/components/doclist/window/FinderWindow.tsx b/src/components/doclist/window/FinderWindow.tsx index c3f9bb5..2c10050 100644 --- a/src/components/doclist/window/FinderWindow.tsx +++ b/src/components/doclist/window/FinderWindow.tsx @@ -1,7 +1,7 @@ 'use client'; -import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'; -import { Fragment, useEffect, useState, type ReactNode } from 'react'; +import type { ReactNode } from 'react'; +import { LibraryFrame, useIsNarrow } from '@/components/layout'; interface FinderWindowProps { toolbar: ReactNode; @@ -14,22 +14,7 @@ interface FinderWindowProps { onRequestSidebarClose?: () => void; } -const NARROW_QUERY = '(max-width: 767px)'; - -export function useIsNarrow(): boolean { - const [isNarrow, setIsNarrow] = useState(() => - typeof window !== 'undefined' ? window.matchMedia(NARROW_QUERY).matches : false, - ); - useEffect(() => { - if (typeof window === 'undefined') return; - const mq = window.matchMedia(NARROW_QUERY); - const update = () => setIsNarrow(mq.matches); - update(); - mq.addEventListener('change', update); - return () => mq.removeEventListener('change', update); - }, []); - return isNarrow; -} +export { useIsNarrow }; /** * Finder-style file pane: sidebar + toolbar + content + status bar. @@ -43,60 +28,15 @@ export function FinderWindow({ sidebarOpen, onRequestSidebarClose, }: FinderWindowProps) { - const isNarrow = useIsNarrow(); - return ( -
- {toolbar} - -
- {!isNarrow && sidebarOpen && ( -
{sidebar}
- )} - -
- {children} -
-
- - {statusBar} - - {/* Mobile drawer */} - - undefined)} - className="relative z-40 md:hidden" - > - -
- -
- - - {sidebar} - - -
-
-
-
+ + {children} + ); } diff --git a/src/components/documents/DexieMigrationModal.tsx b/src/components/documents/DexieMigrationModal.tsx index f8dc6e1..6993eab 100644 --- a/src/components/documents/DexieMigrationModal.tsx +++ b/src/components/documents/DexieMigrationModal.tsx @@ -1,12 +1,13 @@ 'use client'; import { Fragment, useCallback, useEffect, useState } from 'react'; -import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Button } from '@headlessui/react'; +import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie'; import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; import { useDocuments } from '@/contexts/DocumentContext'; import type { BaseDocument } from '@/types/documents'; import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents'; +import { Button, dialogPanelStyles } from '@/components/ui'; type DexieMigrationModalProps = { isOpen: boolean; @@ -161,7 +162,7 @@ export function DexieMigrationModal({ leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - + {title} @@ -187,24 +188,18 @@ export function DexieMigrationModal({
diff --git a/src/components/documents/DocumentSelectionModal.tsx b/src/components/documents/DocumentSelectionModal.tsx index 5a29cda..4119f37 100644 --- a/src/components/documents/DocumentSelectionModal.tsx +++ b/src/components/documents/DocumentSelectionModal.tsx @@ -3,7 +3,7 @@ import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; import { Fragment, useEffect, useState } from 'react'; import { BaseDocument } from '@/types/documents'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button, dialogPanelStyles } from '@/components/ui'; interface DocumentSelectionModalProps { isOpen: boolean; @@ -139,7 +139,7 @@ export function DocumentSelectionModal({ leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - +
- - +
diff --git a/src/components/documents/DocumentUploader.tsx b/src/components/documents/DocumentUploader.tsx index b83e682..4f1a07c 100644 --- a/src/components/documents/DocumentUploader.tsx +++ b/src/components/documents/DocumentUploader.tsx @@ -6,6 +6,7 @@ import { UploadIcon } from '@/components/icons/Icons'; import { useDocuments } from '@/contexts/DocumentContext'; import { uploadDocxAsPdf } from '@/lib/client/api/documents'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; +import { dropzoneSurfaceClass } from '@/components/ui'; interface DocumentUploaderProps { className?: string; @@ -155,24 +156,7 @@ export function DocumentUploader({ noKeyboard: variant === 'overlay' }); - const containerBase = `group w-full rounded transition duration-base ease-standard ${ - isUploading || isConverting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer' - } ${className}`; - - const borderBgClass = - variant === 'compact' - ? `${ - isDragActive - ? 'border border-accent bg-surface-sunken text-accent' - : 'border border-dashed border-line text-foreground hover:border-accent hover:bg-accent-wash hover:text-accent' - }` - : `${ - isDragActive - ? 'border-2 border-dashed border-accent bg-surface text-foreground' - : 'border-2 border-dashed border-muted bg-transparent text-foreground hover:border-accent hover:bg-surface' - }`; - - const paddingClass = variant === 'compact' ? 'py-1 px-2 rounded-md' : 'py-5 px-3 rounded-lg'; + const isDisabled = isUploading || isConverting; if (variant === 'overlay') { const rootProps = getRootProps(); @@ -183,7 +167,7 @@ export function DocumentUploader({ {isDragActive && (
- +

Drop files here to upload

@@ -212,7 +196,12 @@ export function DocumentUploader({ return (
{variant === 'compact' ? ( diff --git a/src/components/documents/ZoomControl.tsx b/src/components/documents/ZoomControl.tsx index 014c2fa..0d88f0b 100644 --- a/src/components/documents/ZoomControl.tsx +++ b/src/components/documents/ZoomControl.tsx @@ -1,4 +1,4 @@ -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { buttonClass } from '@/components/ui'; export function ZoomControl({ value, diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx new file mode 100644 index 0000000..7edc198 --- /dev/null +++ b/src/components/layout/AppHeader.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/components/ui/cn'; + +export function AppHeader({ + left, + title, + right, + className, +}: { + left?: ReactNode; + title?: ReactNode; + right?: ReactNode; + className?: string; +}) { + return ( +
+
+
+ {left} + {typeof title === 'string' ? ( +

{title}

+ ) : ( + title + )} +
+
{right}
+
+
+ ); +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..f636d11 --- /dev/null +++ b/src/components/layout/AppShell.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/components/ui/cn'; + +export function AppShell({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +export function AppMain({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
; +} diff --git a/src/components/layout/LibraryFrame.tsx b/src/components/layout/LibraryFrame.tsx new file mode 100644 index 0000000..de1bad5 --- /dev/null +++ b/src/components/layout/LibraryFrame.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'; +import { Fragment, useEffect, useState, type ReactNode } from 'react'; +import { cn } from '@/components/ui/cn'; + +const NARROW_QUERY = '(max-width: 767px)'; + +export function useIsNarrow(): boolean { + const [isNarrow, setIsNarrow] = useState(() => + typeof window !== 'undefined' ? window.matchMedia(NARROW_QUERY).matches : false, + ); + useEffect(() => { + if (typeof window === 'undefined') return; + const mq = window.matchMedia(NARROW_QUERY); + const update = () => setIsNarrow(mq.matches); + update(); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }, []); + return isNarrow; +} + +export function LibraryFrame({ + toolbar, + sidebar, + statusBar, + children, + sidebarOpen, + onRequestSidebarClose, + className, +}: { + toolbar: ReactNode; + sidebar: ReactNode; + statusBar: ReactNode; + children: ReactNode; + sidebarOpen: boolean; + onRequestSidebarClose?: () => void; + className?: string; +}) { + const isNarrow = useIsNarrow(); + + return ( +
+ {toolbar} +
+ {!isNarrow && sidebarOpen &&
{sidebar}
} +
{children}
+
+ {statusBar} + + {sidebar} + +
+ ); +} + +function LibrarySidebarDrawer({ + open, + onClose, + children, +}: { + open: boolean; + onClose?: () => void; + children: ReactNode; +}) { + return ( + + undefined)} className="relative z-40 md:hidden"> + +
+ +
+ + + {children} + + +
+
+
+ ); +} diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts new file mode 100644 index 0000000..2c5bdf6 --- /dev/null +++ b/src/components/layout/index.ts @@ -0,0 +1,3 @@ +export * from './AppHeader'; +export * from './AppShell'; +export * from './LibraryFrame'; diff --git a/src/components/player/Navigator.tsx b/src/components/player/Navigator.tsx index 5ac48dc..c43b295 100644 --- a/src/components/player/Navigator.tsx +++ b/src/components/player/Navigator.tsx @@ -1,7 +1,8 @@ 'use client'; -import { Button, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; +import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; import { useState, useEffect, useRef } from 'react'; +import { IconButton, Input, cn, popoverPanelClass, popoverTriggerClass } from '@/components/ui'; export const Navigator = ({ currentPage, numPages, skipToLocation }: { currentPage: number; @@ -53,36 +54,37 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: { return (
{/* Page back */} - + {/* Page number popup */}

{currentPage} / {numPages || 1}

- +
Go to page
- {/* Page forward */} - +
); } diff --git a/src/components/player/RateLimitPauseButton.tsx b/src/components/player/RateLimitPauseButton.tsx index 44be7e9..7f4a9e5 100644 --- a/src/components/player/RateLimitPauseButton.tsx +++ b/src/components/player/RateLimitPauseButton.tsx @@ -1,8 +1,8 @@ 'use client'; -import { Button } from '@headlessui/react'; import { PauseIcon } from '@/components/icons/Icons'; import { useTTS } from '@/contexts/TTSContext'; +import { IconButton } from '@/components/ui'; export function RateLimitPauseButton() { const { isPlaying, togglePlay } = useTTS(); @@ -12,14 +12,13 @@ export function RateLimitPauseButton() { if (!isPlaying) return null; return ( - + ); } diff --git a/src/components/player/SpeedControl.tsx b/src/components/player/SpeedControl.tsx index 2da7074..eae7f04 100644 --- a/src/components/player/SpeedControl.tsx +++ b/src/components/player/SpeedControl.tsx @@ -1,10 +1,11 @@ 'use client'; -import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; +import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons'; import { useConfig } from '@/contexts/ConfigContext'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; +import { popoverPanelClass, popoverTriggerClass, rangeInputClass } from '@/components/ui'; export const SpeedControl = ({ setSpeedAndRestart, @@ -88,13 +89,13 @@ export const SpeedControl = ({ return ( - + {compactTriggerLabel} {triggerLabel} - +
{!nativeSpeedSupported && (
@@ -112,7 +113,7 @@ export const SpeedControl = ({ {max.toFixed(1)}x
-
)} @@ -136,7 +137,7 @@ export const SpeedControl = ({ {max.toFixed(1)}x
-
diff --git a/src/components/player/TTSPlayer.tsx b/src/components/player/TTSPlayer.tsx index 2554413..94dea14 100644 --- a/src/components/player/TTSPlayer.tsx +++ b/src/components/player/TTSPlayer.tsx @@ -1,7 +1,6 @@ 'use client'; import { useTTS } from '@/contexts/TTSContext'; -import { Button } from '@headlessui/react'; import { PlayIcon, PauseIcon, @@ -12,6 +11,7 @@ import { LoadingSpinner } from '@/components/Spinner'; import { VoicesControl } from '@/components/player/VoicesControl'; import { SpeedControl } from '@/components/player/SpeedControl'; import { Navigator } from '@/components/player/Navigator'; +import { IconButton } from '@/components/ui'; export default function TTSPlayer({ currentPage, numPages }: { currentPage?: number; @@ -49,32 +49,29 @@ export default function TTSPlayer({ currentPage, numPages }: { )} {/* Playback Controls */} - + - + - + {/* Voice control */} diff --git a/src/components/ui/dropzone.ts b/src/components/ui/dropzone.ts new file mode 100644 index 0000000..d0498fd --- /dev/null +++ b/src/components/ui/dropzone.ts @@ -0,0 +1,40 @@ +import { cn } from './cn'; +import { variants } from './variants'; + +export type DropzoneVariant = 'default' | 'compact'; + +export const dropzoneStyles = variants({ + base: 'group w-full cursor-pointer border-dashed text-foreground transition-colors duration-base ease-standard disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50', + variants: { + variant: { + default: 'rounded-lg border-2 px-3 py-5', + compact: 'rounded-md border px-2 py-1', + }, + active: { + true: 'border-accent bg-surface text-accent', + false: 'border-line bg-transparent hover:border-accent hover:bg-accent-wash hover:text-accent', + }, + }, + defaults: { + variant: 'default', + active: 'false', + }, +}); + +export function dropzoneSurfaceClass({ + variant = 'default', + active = false, + disabled = false, + className, +}: { + variant?: DropzoneVariant; + active?: boolean; + disabled?: boolean; + className?: string; +}) { + return dropzoneStyles({ + variant, + active: active ? 'true' : 'false', + className: cn(disabled && 'cursor-not-allowed opacity-50', className), + }); +} diff --git a/src/components/ui/icon-button.tsx b/src/components/ui/icon-button.tsx index a2442cb..d89f9ed 100644 --- a/src/components/ui/icon-button.tsx +++ b/src/components/ui/icon-button.tsx @@ -1,4 +1,4 @@ -import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'; import { cn } from './cn'; import { variants } from './variants'; import { focusRing, motionColors } from './tokens'; @@ -27,21 +27,21 @@ export const iconButtonStyles = variants({ }, }); -export function IconButton({ +export const IconButton = forwardRef & { + children: ReactNode; + tone?: IconButtonTone; + size?: IconButtonSize; +}>(function IconButton({ className, children, tone = 'ghost', size = 'md', type = 'button', ...props -}: ButtonHTMLAttributes & { - children: ReactNode; - tone?: IconButtonTone; - size?: IconButtonSize; -}) { +}, ref) { return ( - ); -} +}); diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index cf218f0..da14cf7 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -3,10 +3,13 @@ export * from './button'; export * from './cn'; export * from './dialog'; export * from './divider'; +export * from './dropzone'; export * from './field'; export * from './icon-button'; export * from './input'; export * from './menu'; +export * from './popover'; +export * from './range'; export * from './section'; export * from './select'; export * from './sidebar'; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index a252595..9d59894 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,4 +1,4 @@ -import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react'; +import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react'; import { cn } from './cn'; import { variants } from './variants'; import { motionColors } from './tokens'; @@ -21,13 +21,13 @@ export const inputStyles = variants({ export const inputClass = inputStyles(); -export function Input({ +export const Input = forwardRef & { controlSize?: InputControlSize }>(function Input({ className, controlSize = 'md', ...props -}: InputHTMLAttributes & { controlSize?: InputControlSize }) { - return ; -} +}, ref) { + return ; +}); export function Textarea({ className, diff --git a/src/components/ui/popover.ts b/src/components/ui/popover.ts new file mode 100644 index 0000000..9f219d6 --- /dev/null +++ b/src/components/ui/popover.ts @@ -0,0 +1,9 @@ +import { cn } from './cn'; + +export const popoverPanelClass = cn( + 'z-50 rounded-md border border-line bg-surface p-3 shadow-elev-2 focus:outline-none', +); + +export const popoverTriggerClass = cn( + 'inline-flex items-center rounded-md text-foreground hover:bg-accent-wash hover:text-accent focus:outline-none transition-colors duration-fast ease-standard', +); diff --git a/src/components/ui/range.ts b/src/components/ui/range.ts new file mode 100644 index 0000000..37818f0 --- /dev/null +++ b/src/components/ui/range.ts @@ -0,0 +1,9 @@ +import { cn } from './cn'; + +export const rangeInputClass = cn( + 'w-full cursor-pointer appearance-none rounded-lg bg-surface-sunken accent-accent', + '[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-runnable-track]:bg-surface-sunken', + '[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent', + '[&::-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', +); diff --git a/src/components/ui/toolbar.tsx b/src/components/ui/toolbar.tsx index 0b6369b..a91a052 100644 --- a/src/components/ui/toolbar.tsx +++ b/src/components/ui/toolbar.tsx @@ -1,4 +1,4 @@ -import type { HTMLAttributes, ReactNode } from 'react'; +import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react'; import { cn } from './cn'; import { variants } from './variants'; import { motionColors } from './tokens'; @@ -34,7 +34,7 @@ export function ToolbarButton({ children, type = 'button', ...props -}: React.ButtonHTMLAttributes & { +}: ButtonHTMLAttributes & { active?: boolean; children: ReactNode; }) { @@ -63,7 +63,7 @@ export function ToolbarSegment({ children, type = 'button', ...props -}: React.ButtonHTMLAttributes & { +}: ButtonHTMLAttributes & { active?: boolean; children: ReactNode; }) {