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.
This commit is contained in:
parent
5d0d3d313d
commit
4086f80066
32 changed files with 398 additions and 669 deletions
|
|
@ -1,5 +0,0 @@
|
|||
import { UiHarness } from '@/components/dev/UiHarness';
|
||||
|
||||
export default function UiDevPage() {
|
||||
return <UiHarness />;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p className="text-danger mb-4">{error}</p>
|
||||
<Link
|
||||
href="/app"
|
||||
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
|
||||
>
|
||||
<ButtonLink href="/app" variant="secondary" size="md" className="gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Documents
|
||||
</Link>
|
||||
</ButtonLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -189,16 +185,12 @@ export default function EPUBPage() {
|
|||
<>
|
||||
<Header
|
||||
left={
|
||||
<Link
|
||||
href="/app"
|
||||
className={buttonClass({ variant: 'secondary', size: 'sm', className: 'gap-2' })}
|
||||
aria-label="Back to documents"
|
||||
>
|
||||
<ButtonLink href="/app" variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
|
||||
<svg className="w-3 h-3" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Documents
|
||||
</Link>
|
||||
</ButtonLink>
|
||||
}
|
||||
title={isLoading ? 'Loading…' : (currDocName || '')}
|
||||
right={
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p className="text-danger mb-4">{error}</p>
|
||||
<Link
|
||||
href="/app"
|
||||
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
|
||||
>
|
||||
<ButtonLink href="/app" variant="secondary" size="md" className="gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Documents
|
||||
</Link>
|
||||
</ButtonLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -176,16 +172,12 @@ export default function HTMLPage() {
|
|||
<>
|
||||
<Header
|
||||
left={
|
||||
<Link
|
||||
href="/app"
|
||||
className={buttonClass({ variant: 'secondary', size: 'sm', className: 'gap-2' })}
|
||||
aria-label="Back to documents"
|
||||
>
|
||||
<ButtonLink href="/app" variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
|
||||
<svg className="w-3 h-3" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Documents
|
||||
</Link>
|
||||
</ButtonLink>
|
||||
}
|
||||
title={isLoading ? 'Loading…' : (currDocName || '')}
|
||||
right={
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p className="text-danger mb-4">{error}</p>
|
||||
<Link
|
||||
href="/app"
|
||||
onClick={handleBackToDocuments}
|
||||
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
|
||||
>
|
||||
<ButtonLink href="/app" onClick={handleBackToDocuments} variant="secondary" size="md" className="gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Documents
|
||||
</Link>
|
||||
</ButtonLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -343,13 +338,14 @@ export default function PDFViewerPage() {
|
|||
|
||||
{!isLoading && parseUiState === 'failed' ? (
|
||||
<div className="mt-3 flex justify-start">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={requestForceReparse}
|
||||
className={buttonClass({ variant: 'secondary', size: 'sm' })}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Retry Parse
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -382,17 +378,12 @@ export default function PDFViewerPage() {
|
|||
<>
|
||||
<Header
|
||||
left={
|
||||
<Link
|
||||
href="/app"
|
||||
onClick={handleBackToDocuments}
|
||||
className={buttonClass({ variant: 'secondary', size: 'sm', className: 'gap-2' })}
|
||||
aria-label="Back to documents"
|
||||
>
|
||||
<ButtonLink href="/app" onClick={handleBackToDocuments} variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
|
||||
<svg className="w-3 h-3" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Documents
|
||||
</Link>
|
||||
</ButtonLink>
|
||||
}
|
||||
title={isLoading ? 'Loading…' : (currDocName || '')}
|
||||
right={
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</a>
|
||||
<span className="public-nav-divider" aria-hidden="true" />
|
||||
<Link href="/signin" className={buttonClass({ variant: 'ghost', size: 'sm' })}>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'sm' })}>
|
||||
Open app
|
||||
</Link>
|
||||
<ButtonLink href="/signin" variant="ghost" size="sm">Sign in</ButtonLink>
|
||||
<ButtonLink href="/app" variant="primary" size="sm">Open app</ButtonLink>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
|
|
@ -70,18 +66,11 @@ export default async function PublicLayout({ children }: { children: ReactNode }
|
|||
</p>
|
||||
<div className="public-footer-cta">
|
||||
{enableUserSignups ? (
|
||||
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'sm' })}>
|
||||
Create account
|
||||
</Link>
|
||||
<ButtonLink href="/signup" variant="outline" size="sm">Create account</ButtonLink>
|
||||
) : null}
|
||||
<a
|
||||
href="https://github.com/richardr1126/openreader"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonClass({ variant: 'ghost', size: 'sm' })}
|
||||
>
|
||||
<ButtonAnchor href="https://github.com/richardr1126/openreader" target="_blank" rel="noopener noreferrer" variant="ghost" size="sm">
|
||||
Star on GitHub
|
||||
</a>
|
||||
</ButtonAnchor>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</p>
|
||||
|
||||
<div className="public-actions">
|
||||
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'lg' })}>
|
||||
Open the reader
|
||||
</Link>
|
||||
<ButtonLink href="/app" variant="primary" size="lg">Open the reader</ButtonLink>
|
||||
{enableUserSignups ? (
|
||||
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'lg' })}>
|
||||
Create account
|
||||
</Link>
|
||||
<ButtonLink href="/signup" variant="outline" size="lg">Create account</ButtonLink>
|
||||
) : (
|
||||
<Link href="/signin" className={buttonClass({ variant: 'outline', size: 'lg' })}>
|
||||
Sign in
|
||||
</Link>
|
||||
<ButtonLink href="/signin" variant="outline" size="lg">Sign in</ButtonLink>
|
||||
)}
|
||||
<Link
|
||||
href="https://docs.openreader.richardr.dev/"
|
||||
className={buttonClass({ variant: 'ghost', size: 'lg' })}
|
||||
>
|
||||
Read the docs →
|
||||
</Link>
|
||||
<ButtonLink href="https://docs.openreader.richardr.dev/" variant="ghost" size="lg">Read the docs →</ButtonLink>
|
||||
</div>
|
||||
|
||||
<div className="public-formats" aria-label="Supported formats">
|
||||
|
|
@ -316,22 +305,12 @@ export default async function LandingPage() {
|
|||
external compute worker. Every piece is yours to host.
|
||||
</p>
|
||||
<div className="public-actions">
|
||||
<a
|
||||
href="https://github.com/richardr1126/openreader#readme"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonClass({ variant: 'primary', size: 'lg' })}
|
||||
>
|
||||
<ButtonAnchor href="https://github.com/richardr1126/openreader#readme" target="_blank" rel="noopener noreferrer" variant="primary" size="lg">
|
||||
View the repository
|
||||
</a>
|
||||
<a
|
||||
href="https://docs.openreader.richardr.dev/docker-quick-start"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonClass({ variant: 'outline', size: 'lg' })}
|
||||
>
|
||||
</ButtonAnchor>
|
||||
<ButtonAnchor href="https://docs.openreader.richardr.dev/docker-quick-start" target="_blank" rel="noopener noreferrer" variant="outline" size="lg">
|
||||
Deployment guides
|
||||
</a>
|
||||
</ButtonAnchor>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
</p>
|
||||
<div className="policy-actions">
|
||||
<a
|
||||
href="https://github.com/richardr1126/openreader/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonClass({ variant: 'primary', size: 'md' })}
|
||||
>
|
||||
<ButtonAnchor href="https://github.com/richardr1126/openreader/issues" target="_blank" rel="noopener noreferrer" variant="primary" size="md">
|
||||
Open an Issue
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/richardr1126/openreader#readme"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonClass({ variant: 'outline', size: 'md' })}
|
||||
>
|
||||
</ButtonAnchor>
|
||||
<ButtonAnchor href="https://github.com/richardr1126/openreader#readme" target="_blank" rel="noopener noreferrer" variant="outline" size="md">
|
||||
Self-Hosting Guide
|
||||
</a>
|
||||
<Link href="/?redirect=false" className={buttonClass({ variant: 'ghost', size: 'md' })}>
|
||||
Back to landing
|
||||
</Link>
|
||||
</ButtonAnchor>
|
||||
<ButtonLink href="/?redirect=false" variant="ghost" size="md">Back to landing</ButtonLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Native model speed</label>
|
||||
<span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(nativeSpeed)}x</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
<RangeInput
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={nativeSpeed}
|
||||
onChange={(e) => setNativeSpeed(parseFloat(e.target.value))}
|
||||
className={rangeInputClass}
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-soft">
|
||||
<span>0.5x</span>
|
||||
|
|
@ -645,14 +643,12 @@ export function AudiobookExportModal({
|
|||
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Post-generation speed</label>
|
||||
<span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(postSpeed)}x</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
<RangeInput
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={postSpeed}
|
||||
onChange={(e) => setPostSpeed(parseFloat(e.target.value))}
|
||||
className={rangeInputClass}
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-soft">
|
||||
<span>0.5x</span>
|
||||
|
|
@ -794,68 +790,48 @@ export function AudiobookExportModal({
|
|||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
<MenuItemsSurface
|
||||
anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }}
|
||||
portal
|
||||
className={cn(menuPanelClass, 'z-[70] w-44 origin-top-right bg-background focus:outline-none')}
|
||||
className="z-[70] w-44 origin-top-right bg-background focus:outline-none"
|
||||
>
|
||||
{chapter.status === 'completed' && (
|
||||
<>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setPendingDeleteChapter(chapter)}
|
||||
className={MenuItemClass(active, 'danger')}
|
||||
title="Delete this chapter"
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => handleDownloadChapter(chapter)}
|
||||
className={MenuItemClass(active)}
|
||||
>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
<span>Download</span>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuActionItem
|
||||
tone="danger"
|
||||
onClick={() => setPendingDeleteChapter(chapter)}
|
||||
title="Delete this chapter"
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</MenuActionItem>
|
||||
<MenuActionItem onClick={() => handleDownloadChapter(chapter)}>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
<span>Download</span>
|
||||
</MenuActionItem>
|
||||
</>
|
||||
)}
|
||||
{regeneratingChapter === chapter.index && (
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className={MenuItemClass(active, 'danger')}
|
||||
title="Cancel this chapter regeneration"
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuActionItem
|
||||
tone="danger"
|
||||
onClick={handleCancel}
|
||||
title="Cancel this chapter regeneration"
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
<span>Cancel</span>
|
||||
</MenuActionItem>
|
||||
)}
|
||||
{onRegenerateChapter && !isGenerating && (
|
||||
<MenuItem disabled={regeneratingChapter !== null}>
|
||||
{({ active, disabled }) => (
|
||||
<button
|
||||
onClick={() => handleRegenerateChapter(chapter)}
|
||||
disabled={disabled}
|
||||
className={cn(MenuItemClass(active), 'disabled:cursor-not-allowed disabled:opacity-50')}
|
||||
title="Regenerate this chapter"
|
||||
>
|
||||
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
|
||||
<span>{regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'}</span>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuActionItem
|
||||
disabled={regeneratingChapter !== null}
|
||||
onClick={() => handleRegenerateChapter(chapter)}
|
||||
title="Regenerate this chapter"
|
||||
>
|
||||
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
|
||||
<span>{regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'}</span>
|
||||
</MenuActionItem>
|
||||
)}
|
||||
</MenuItems>
|
||||
</MenuItemsSurface>
|
||||
{/* end of menu items */}
|
||||
</Transition>
|
||||
</Menu>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
<button
|
||||
<Button
|
||||
onClick={handleCopy}
|
||||
className={buttonClass({
|
||||
variant: 'secondary',
|
||||
size: 'icon',
|
||||
className: 'absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100',
|
||||
})}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? <CheckIcon className="w-4 h-4" /> : <CopyIcon className="w-4 h-4" />}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
|||
/>
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel
|
||||
<PopoverSurface
|
||||
anchor="bottom start"
|
||||
transition
|
||||
className={cn(
|
||||
popoverPanelClass,
|
||||
'z-[60] mt-2 w-56 bg-background space-y-3 transition duration-fast ease-standard data-[closed]:opacity-0 data-[closed]:scale-95',
|
||||
)}
|
||||
className="z-[60] mt-2 w-56 bg-background space-y-3 transition duration-fast ease-standard data-[closed]:opacity-0 data-[closed]:scale-95"
|
||||
>
|
||||
{/* Label */}
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -173,7 +170,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
|
|||
className="flex-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</PopoverSurface>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 min-w-fit sm:flex-row">
|
||||
<button
|
||||
<Button
|
||||
onClick={handleDecline}
|
||||
className={buttonClass({
|
||||
variant: 'ghost',
|
||||
size: 'md',
|
||||
className: 'whitespace-nowrap',
|
||||
})}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Decline Non-Essential
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAccept}
|
||||
className={buttonClass({
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
className: 'whitespace-nowrap font-bold shadow-elev-1',
|
||||
})}
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="whitespace-nowrap font-bold shadow-elev-1"
|
||||
>
|
||||
Accept All
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className={buttonClass({
|
||||
variant: 'ghost',
|
||||
size: 'xs',
|
||||
className: 'shrink-0 hover:bg-accent-wash',
|
||||
})}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={(e) => onCancel(e)}
|
||||
>
|
||||
<span>{cancelText}</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
}}
|
||||
>
|
||||
<ListboxButton className={listboxButtonClass}>
|
||||
<SharedListboxButton>
|
||||
<span className="block truncate">
|
||||
{selectedProviderOption?.name || 'Select Provider'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-soft" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
<SharedListboxOptions
|
||||
anchor="bottom start"
|
||||
className={listboxOptionsClass}
|
||||
>
|
||||
{ttsProviders.map((provider) => (
|
||||
<ListboxOption
|
||||
<SharedListboxOption
|
||||
key={provider.id}
|
||||
className={({ active }) => listboxOptionClass(active)}
|
||||
value={provider}
|
||||
>
|
||||
{({ selected }) => (
|
||||
|
|
@ -728,9 +723,9 @@ export function SettingsModal({
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
)}
|
||||
|
|
@ -797,7 +792,7 @@ export function SettingsModal({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<ListboxButton className={listboxButtonClass}>
|
||||
<SharedListboxButton>
|
||||
{selectedModel ? (
|
||||
<span className="block">
|
||||
<span className="block truncate">
|
||||
|
|
@ -815,21 +810,19 @@ export function SettingsModal({
|
|||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-soft" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
<SharedListboxOptions
|
||||
anchor="bottom start"
|
||||
className={listboxOptionsClass}
|
||||
>
|
||||
{ttsModels.map((model) => (
|
||||
<ListboxOption
|
||||
<SharedListboxOption
|
||||
key={model.id}
|
||||
className={({ active }) => listboxOptionClass(active)}
|
||||
value={model}
|
||||
>
|
||||
{({ selected }) => (
|
||||
|
|
@ -849,9 +842,9 @@ export function SettingsModal({
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
{providerOptions.length > 0 ? (
|
||||
<Listbox value={selectedProviderOption} onChange={handleProviderChange}>
|
||||
<ListboxButton className={listboxButtonClass}>
|
||||
<SharedListboxButton>
|
||||
<span className="block truncate">{selectedProviderOption?.name ?? 'Select provider'}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
|
||||
<SharedListboxOptions anchor="bottom start">
|
||||
{providerOptions.map((opt) => (
|
||||
<ListboxOption
|
||||
<SharedListboxOption
|
||||
key={opt.id}
|
||||
value={opt}
|
||||
className={({ active }) => listboxOptionClass(active)}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
|
|
@ -236,9 +235,9 @@ export function AdminFeaturesPanel() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
}}
|
||||
>
|
||||
<ListboxButton className={listboxButtonClass}>
|
||||
<SharedListboxButton>
|
||||
<span className="block truncate">{selectedProviderType.label}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
|
||||
<SharedListboxOptions anchor="bottom start">
|
||||
{PROVIDER_TYPE_OPTIONS.map((opt) => (
|
||||
<ListboxOption
|
||||
<SharedListboxOption
|
||||
key={opt.value}
|
||||
value={opt}
|
||||
className={({ active }) => listboxOptionClass(active)}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
|
|
@ -457,9 +455,9 @@ export function AdminProvidersPanel() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
</Field>
|
||||
|
|
@ -485,26 +483,25 @@ export function AdminProvidersPanel() {
|
|||
setCustomModelInput('');
|
||||
}}
|
||||
>
|
||||
<ListboxButton className={listboxButtonClass}>
|
||||
<SharedListboxButton>
|
||||
<span className="block truncate">
|
||||
{selectedModelDefinition?.name ?? 'Select model'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
|
||||
<SharedListboxOptions anchor="bottom start">
|
||||
{modelDefinitions.map((model) => (
|
||||
<ListboxOption
|
||||
<SharedListboxOption
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
className={({ active }) => listboxOptionClass(active)}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
|
|
@ -523,9 +520,9 @@ export function AdminProvidersPanel() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
{supportsCustomModel && selectedModelId === 'custom' && (
|
||||
|
|
@ -663,56 +660,26 @@ export function AdminProvidersPanel() {
|
|||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
<MenuItemsSurface
|
||||
anchor="bottom end"
|
||||
className={cn(menuPanelClass, 'z-50 mt-2 min-w-[170px] bg-base focus:outline-none')}
|
||||
className="z-50 mt-2 min-w-[170px] bg-base focus:outline-none"
|
||||
>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEdit(p)}
|
||||
className={MenuItemClass(active)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem disabled={!p.enabled || defaultProviderSlug === p.slug}>
|
||||
{({ active, disabled }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDefault(p.slug)}
|
||||
disabled={disabled}
|
||||
className={cn(MenuItemClass(active), disabled ? 'cursor-not-allowed text-faint' : '')}
|
||||
>
|
||||
Set as default
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEnabled(p)}
|
||||
className={MenuItemClass(active)}
|
||||
>
|
||||
{p.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(p.id)}
|
||||
className={MenuItemClass(active, 'danger')}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
<MenuActionItem onClick={() => startEdit(p)}>
|
||||
Edit
|
||||
</MenuActionItem>
|
||||
<MenuActionItem
|
||||
onClick={() => setDefault(p.slug)}
|
||||
disabled={!p.enabled || defaultProviderSlug === p.slug}
|
||||
>
|
||||
Set as default
|
||||
</MenuActionItem>
|
||||
<MenuActionItem onClick={() => toggleEnabled(p)}>
|
||||
{p.enabled ? 'Disable' : 'Enable'}
|
||||
</MenuActionItem>
|
||||
<MenuActionItem tone="danger" onClick={() => remove(p.id)}>
|
||||
Delete
|
||||
</MenuActionItem>
|
||||
</MenuItemsSurface>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={`flex gap-2 ${className}`}>
|
||||
<Link href="/signin" className={buttonClass({ variant: 'secondary', size: 'sm' })}>
|
||||
<ButtonLink href="/signin" variant="secondary" size="sm">
|
||||
Connect
|
||||
</Link>
|
||||
</ButtonLink>
|
||||
{enableUserSignups && (
|
||||
<Link href="/signup" className={buttonClass({ variant: 'primary', size: 'sm' })}>
|
||||
<ButtonLink href="/signup" variant="primary" size="sm">
|
||||
Create account
|
||||
</Link>
|
||||
</ButtonLink>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<main className="min-h-[calc(100vh-3rem)] bg-background px-4 py-6 text-foreground sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-5">
|
||||
<header className="flex flex-col gap-3 rounded-lg border border-line bg-surface p-4 shadow-elev-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<Badge tone="accent">Authenticated dev surface</Badge>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">UI system harness</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-soft">
|
||||
Review shared primitives, state tokens, and theme behavior from the authenticated app shell.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary">Primary action</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Section
|
||||
title="Theme Matrix"
|
||||
subtitle="Switches the real document theme so token regressions are visible in context."
|
||||
>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(7rem,1fr))] gap-2">
|
||||
{THEME_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setTheme(option)}
|
||||
className={buttonClass({
|
||||
variant: theme === option ? 'primary' : 'outline',
|
||||
size: 'sm',
|
||||
className: 'capitalize',
|
||||
})}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<Panel className="space-y-4 p-4" elevation="2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
<Button variant="outline">Outline</Button>
|
||||
<Button variant="ghost">Ghost</Button>
|
||||
<Button variant="danger">Danger</Button>
|
||||
<Button variant="secondary" disabled>Disabled</Button>
|
||||
<IconButton aria-label="Refresh sample">↻</IconButton>
|
||||
<IconButton aria-label="Danger sample" tone="danger">×</IconButton>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Reader name" hint="Field, input, and hint tokens.">
|
||||
<Input placeholder="OpenReader" />
|
||||
</Field>
|
||||
<Field label="Mode">
|
||||
<Select
|
||||
value={selectValue}
|
||||
onChange={setSelectValue}
|
||||
options={[
|
||||
{ value: 'library', label: 'Library' },
|
||||
{ value: 'reader', label: 'Reader' },
|
||||
{ value: 'player', label: 'Player' },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Notes" className="md:col-span-2">
|
||||
<Textarea rows={3} placeholder="Textarea state preview" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<ToggleRow
|
||||
label="Calm motion"
|
||||
description="Feedback-only transitions, no ambient movement."
|
||||
checked={enabled}
|
||||
onChange={setEnabled}
|
||||
/>
|
||||
<Card>
|
||||
<CheckItem label="Parsed text available" checked={checked} onChange={setChecked} />
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-sm text-soft">Direct switch sample</span>
|
||||
<Switch checked={enabled} onChange={setEnabled} ariaLabel="Toggle direct switch sample" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Surface tone="sunken" className="p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">Range control</span>
|
||||
<Badge tone="foreground">1.2x</Badge>
|
||||
</div>
|
||||
<input className={rangeInputClass} type="range" min="0.5" max="3" step="0.1" defaultValue="1.2" />
|
||||
</Surface>
|
||||
</Panel>
|
||||
|
||||
<Sidebar className="min-h-full p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-foreground">Sidebar</span>
|
||||
<Badge>Nav</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{['Library', 'Reader', 'Audio', 'Settings'].map((item, index) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={`w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors duration-fast ease-standard ${
|
||||
index === 0 ? 'bg-accent-wash text-accent' : 'text-soft hover:bg-accent-wash hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Sidebar>
|
||||
</div>
|
||||
|
||||
<Panel elevation="1">
|
||||
<Toolbar className="static">
|
||||
<ToolbarGroup>
|
||||
<ToolbarSegment active>List</ToolbarSegment>
|
||||
<ToolbarSegment>Grid</ToolbarSegment>
|
||||
<ToolbarSegment>Gallery</ToolbarSegment>
|
||||
</ToolbarGroup>
|
||||
<ToolbarButton active>Sort by name</ToolbarButton>
|
||||
<ToolbarButton>Recently opened</ToolbarButton>
|
||||
</Toolbar>
|
||||
<div className="grid gap-4 p-4 md:grid-cols-3">
|
||||
<div className={dropzoneSurfaceClass({ active: false })}>
|
||||
<p className="text-sm font-medium text-foreground">Dropzone idle</p>
|
||||
<p className="mt-1 text-xs text-soft">Border and wash states live in `dropzoneStyles`.</p>
|
||||
</div>
|
||||
<div className={dropzoneSurfaceClass({ active: true })}>
|
||||
<p className="text-sm font-medium">Dropzone active</p>
|
||||
<p className="mt-1 text-xs">Drag feedback uses tokenized state colors.</p>
|
||||
</div>
|
||||
<div className={popoverPanelClass}>
|
||||
<p className="text-sm font-medium text-foreground">Popover panel</p>
|
||||
<p className="mt-1 text-xs text-soft">Shared shell for player and reader controls.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
import { Button, ButtonLink } from '@/components/ui';
|
||||
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
|
||||
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
|
||||
|
||||
|
|
@ -198,20 +198,17 @@ export function GalleryView({
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={openHref || '/app'}
|
||||
prefetch={false}
|
||||
className={buttonClass({ variant: 'primary', size: 'sm' })}
|
||||
>
|
||||
<ButtonLink href={openHref || '/app'} prefetch={false} variant="primary" size="sm">
|
||||
Open
|
||||
</Link>
|
||||
<button
|
||||
</ButtonLink>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onDeleteDoc(activeDoc)}
|
||||
className={buttonClass({ variant: 'secondary', size: 'sm' })}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="w-full max-w-[280px] sm:max-w-[360px] md:max-w-[340px] grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md border border-line bg-surface px-3 py-2 text-[11px] md:self-center">
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
|
||||
import { Menu, MenuButton, Transition } from '@headlessui/react';
|
||||
import { Fragment, useRef, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useDrop } from 'react-dnd';
|
||||
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, SidebarNav, SidebarNavGroup, SidebarNavItem, cn, menuPanelClass } from '@/components/ui';
|
||||
import { IconButton, MenuActionItem, MenuItemsSurface, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui';
|
||||
|
||||
interface FinderSidebarProps {
|
||||
filter: SidebarFilter;
|
||||
|
|
@ -220,44 +220,32 @@ export function FinderSidebar({
|
|||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
<MenuItemsSurface
|
||||
anchor="bottom start"
|
||||
className={`${menuPanelClass} z-50 mt-2 min-w-[180px] focus:outline-none normal-case tracking-normal font-normal`}
|
||||
className="z-50 mt-2 min-w-[180px] focus:outline-none normal-case tracking-normal font-normal"
|
||||
>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onNewFolder();
|
||||
onRowAction?.();
|
||||
}}
|
||||
className={MenuItemClass(active)}
|
||||
>
|
||||
<FolderPlusIcon className="h-4 w-4" />
|
||||
New Folder
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem disabled={folders.length === 0}>
|
||||
{({ active, disabled }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClearFolders();
|
||||
onRowAction?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(MenuItemClass(active), disabled && 'cursor-not-allowed text-faint')}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Remove All Folders
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
<MenuActionItem
|
||||
onClick={() => {
|
||||
onNewFolder();
|
||||
onRowAction?.();
|
||||
}}
|
||||
>
|
||||
<FolderPlusIcon className="h-4 w-4" />
|
||||
New Folder
|
||||
</MenuActionItem>
|
||||
<MenuActionItem
|
||||
disabled={folders.length === 0}
|
||||
onClick={() => {
|
||||
onClearFolders();
|
||||
onRowAction?.();
|
||||
}}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Remove All Folders
|
||||
</MenuActionItem>
|
||||
</MenuItemsSurface>
|
||||
</Transition>
|
||||
</Menu>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
|
||||
import { Menu, MenuButton, Transition } from '@headlessui/react';
|
||||
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';
|
||||
import { IconButton, MenuActionItem, MenuItemsSurface, ToolbarButton } from '@/components/ui';
|
||||
|
||||
interface DocumentHeaderMenuProps {
|
||||
zoomLevel: number;
|
||||
|
|
@ -100,7 +100,7 @@ export function DocumentHeaderMenu({
|
|||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems className={`${menuPanelClass} absolute right-0 z-50 mt-2 min-w-max origin-top-right divide-y divide-line-soft focus:outline-none`}>
|
||||
<MenuItemsSurface className="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>
|
||||
|
|
@ -125,49 +125,28 @@ export function DocumentHeaderMenu({
|
|||
{/* Actions Section */}
|
||||
<div className="p-1">
|
||||
{onOpenSegments && (
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={onOpenSegments}
|
||||
className={MenuItemClass(active || isSegmentsOpen)}
|
||||
>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
{isSegmentsOpen ? 'Hide Segments' : 'Segments'}
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuActionItem onClick={onOpenSegments} activeOverride={isSegmentsOpen}>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
{isSegmentsOpen ? 'Hide Segments' : 'Segments'}
|
||||
</MenuActionItem>
|
||||
)}
|
||||
{showAudiobookExport && onOpenAudiobook && (
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={onOpenAudiobook}
|
||||
className={MenuItemClass(active || isAudiobookOpen)}
|
||||
>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
{isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuActionItem onClick={onOpenAudiobook} activeOverride={isAudiobookOpen}>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
{isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
|
||||
</MenuActionItem>
|
||||
)}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className={MenuItemClass(active || isSettingsOpen)}
|
||||
>
|
||||
<FileSettingsIcon className="h-4 w-4" />
|
||||
{isSettingsOpen ? 'Hide Settings' : 'Settings'}
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuActionItem onClick={onOpenSettings} activeOverride={isSettingsOpen}>
|
||||
<FileSettingsIcon className="h-4 w-4" />
|
||||
{isSettingsOpen ? 'Hide Settings' : 'Settings'}
|
||||
</MenuActionItem>
|
||||
</div>
|
||||
|
||||
{/* Auth Section */}
|
||||
<div className="p-2 border-t border-line-soft flex justify-center">
|
||||
<UserMenu />
|
||||
</div>
|
||||
</MenuItems>
|
||||
</MenuItemsSurface>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
clampSegmentPreloadSentenceLookahead,
|
||||
clampTtsSegmentMaxBlockLength,
|
||||
} from '@/types/config';
|
||||
import { Section, ToggleRow, CheckItem, SegmentedControl, buttonClass, rangeInputClass } from '@/components/ui';
|
||||
import { IconButton, RangeInput, Section, ToggleRow, CheckItem, SegmentedControl } 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';
|
||||
|
|
@ -77,14 +77,13 @@ function RangeSetting({
|
|||
<div className="space-y-1.5">
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">{label}</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
<RangeInput
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className={`flex-1 ${rangeInputClass}`}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className={`${valueWidth} text-xs font-semibold text-right text-foreground`}>{formatter(value)}</span>
|
||||
</div>
|
||||
|
|
@ -202,15 +201,15 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
|||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-muted">
|
||||
<span>{pdf.parseStatus ?? 'pending'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass({ variant: 'ghost', size: 'icon', className: '!h-5 !w-5 shrink-0' })}
|
||||
<IconButton
|
||||
size="xs"
|
||||
className="!h-5 !w-5 shrink-0"
|
||||
onClick={pdf.onForceReparse}
|
||||
disabled={isForceReparseDisabled(pdf.parseStatus)}
|
||||
title="Force reparse"
|
||||
>
|
||||
<RefreshIcon className={`h-3 w-3 ${isForceReparseDisabled(pdf.parseStatus) ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</IconButton>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { buttonClass } from '@/components/ui';
|
||||
import { IconButton } from '@/components/ui';
|
||||
|
||||
export function ZoomControl({
|
||||
value,
|
||||
|
|
@ -15,33 +15,25 @@ export function ZoomControl({
|
|||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 select-none" aria-label="Zoom controls">
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
onClick={onDecrease}
|
||||
disabled={value <= min}
|
||||
className={buttonClass({
|
||||
variant: 'ghost',
|
||||
size: 'icon',
|
||||
className: 'h-6 w-6 text-sm leading-none',
|
||||
})}
|
||||
size="sm"
|
||||
className="h-6 w-6 text-sm leading-none"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
</IconButton>
|
||||
<span className="text-xs tabular-nums w-12 text-center text-soft">{value}%</span>
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
onClick={onIncrease}
|
||||
disabled={value >= max}
|
||||
className={buttonClass({
|
||||
variant: 'ghost',
|
||||
size: 'icon',
|
||||
className: 'h-6 w-6 text-sm leading-none',
|
||||
})}
|
||||
size="sm"
|
||||
className="h-6 w-6 text-sm leading-none"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||
import { Popover } from '@headlessui/react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { IconButton, Input, cn, popoverPanelClass, popoverTriggerClass } from '@/components/ui';
|
||||
import { IconButton, Input, PopoverSurface, PopoverTrigger } from '@/components/ui';
|
||||
|
||||
export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||
currentPage: number;
|
||||
|
|
@ -67,15 +67,12 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
|||
|
||||
{/* Page number popup */}
|
||||
<Popover className="relative mb-1">
|
||||
<PopoverButton
|
||||
className={cn(popoverTriggerClass, 'rounded-full bg-surface-sunken px-2 py-0.5 text-xs')}
|
||||
onClick={handlePopoverOpen}
|
||||
>
|
||||
<PopoverTrigger className="rounded-full bg-surface-sunken px-2 py-0.5 text-xs" onClick={handlePopoverOpen}>
|
||||
<p className="text-xs whitespace-nowrap">
|
||||
{currentPage} / {numPages || 1}
|
||||
</p>
|
||||
</PopoverButton>
|
||||
<PopoverPanel anchor="top" className={popoverPanelClass}>
|
||||
</PopoverTrigger>
|
||||
<PopoverSurface anchor="top">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="text-xs font-medium text-foreground">Go to page</div>
|
||||
<Input
|
||||
|
|
@ -94,7 +91,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
|||
/>
|
||||
<div className="text-xs text-soft text-center">of {numPages || 1}</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</PopoverSurface>
|
||||
</Popover>
|
||||
|
||||
{/* Page forward */}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||
import { Popover } 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';
|
||||
import { PopoverSurface, PopoverTrigger, RangeInput } from '@/components/ui';
|
||||
|
||||
export const SpeedControl = ({
|
||||
setSpeedAndRestart,
|
||||
|
|
@ -89,13 +89,13 @@ export const SpeedControl = ({
|
|||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<PopoverButton className={`${popoverTriggerClass} space-x-0.5 px-1.5 py-0.5 text-xs sm:space-x-1 sm:px-2 sm:py-1 sm:text-sm`}>
|
||||
<PopoverTrigger className="space-x-0.5 px-1.5 py-0.5 text-xs sm:space-x-1 sm:px-2 sm:py-1 sm:text-sm">
|
||||
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
<span className="sm:hidden">{compactTriggerLabel}</span>
|
||||
<span className="hidden sm:inline">{triggerLabel}</span>
|
||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
</PopoverButton>
|
||||
<PopoverPanel anchor="top" className={popoverPanelClass}>
|
||||
</PopoverTrigger>
|
||||
<PopoverSurface anchor="top">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{!nativeSpeedSupported && (
|
||||
<div className="rounded-md border border-line bg-background px-2 py-1.5 text-[11px] text-soft">
|
||||
|
|
@ -113,8 +113,7 @@ export const SpeedControl = ({
|
|||
</span>
|
||||
<span className="text-xs">{max.toFixed(1)}x</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
<RangeInput
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
|
|
@ -123,7 +122,6 @@ export const SpeedControl = ({
|
|||
onMouseUp={handleVoiceSpeedChangeComplete}
|
||||
onKeyUp={handleVoiceSpeedChangeComplete}
|
||||
onTouchEnd={handleVoiceSpeedChangeComplete}
|
||||
className={rangeInputClass}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -137,8 +135,7 @@ export const SpeedControl = ({
|
|||
</span>
|
||||
<span className="text-xs">{max.toFixed(1)}x</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
<RangeInput
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
|
|
@ -147,11 +144,10 @@ export const SpeedControl = ({
|
|||
onMouseUp={handleAudioSpeedChangeComplete}
|
||||
onKeyUp={handleAudioSpeedChangeComplete}
|
||||
onTouchEnd={handleAudioSpeedChangeComplete}
|
||||
className={rangeInputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</PopoverSurface>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||
import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
|
||||
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||
import { SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn, popoverTriggerClass } from '@/components/ui';
|
||||
import { SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn } from '@/components/ui';
|
||||
|
||||
export function VoicesControlBase({
|
||||
availableVoices,
|
||||
|
|
@ -33,8 +33,8 @@ export function VoicesControlBase({
|
|||
|
||||
const buttonClass = variant === 'field'
|
||||
? 'bg-surface pr-10'
|
||||
: cn(popoverTriggerClass, 'space-x-0.5 px-1.5 py-0.5 text-xs sm:space-x-1 sm:px-2 sm:py-1 sm:text-sm');
|
||||
const buttonTone = variant === 'field' ? 'default' : 'unstyled';
|
||||
: 'space-x-0.5 px-1.5 py-0.5 text-xs sm:space-x-1 sm:px-2 sm:py-1 sm:text-sm';
|
||||
const buttonTone = variant === 'field' ? 'default' : 'popover';
|
||||
|
||||
const iconClass = variant === 'field'
|
||||
? 'h-3.5 w-3.5 shrink-0'
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
'use client';
|
||||
|
||||
import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
|
||||
import { Popover, PopoverButton, Transition } from '@headlessui/react';
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Book } from 'epubjs';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
|
||||
import { Button, IconButton, popoverPanelClass } from '@/components/ui';
|
||||
import { Button, IconButton, PopoverSurface } from '@/components/ui';
|
||||
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
|
||||
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
|
||||
|
|
@ -903,9 +903,9 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
|||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel
|
||||
<PopoverSurface
|
||||
anchor="bottom end"
|
||||
className={`${popoverPanelClass} z-[60] w-[300px] mt-1`}
|
||||
className="z-[60] w-[300px] mt-1"
|
||||
>
|
||||
<dl className="space-y-2">
|
||||
<Row label="locator">
|
||||
|
|
@ -958,7 +958,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
|||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</PopoverPanel>
|
||||
</PopoverSurface>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import Link, { type LinkProps } from 'next/link';
|
||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from './cn';
|
||||
import { variants } from './variants';
|
||||
import { focusRing, motionColors } from './tokens';
|
||||
|
|
@ -45,7 +46,7 @@ export const btnOutline = buttonStyles({ variant: 'outline', size: 'md' });
|
|||
export const btnDanger = buttonStyles({ variant: 'danger', size: 'md' });
|
||||
export const btnGhost = buttonStyles({ variant: 'ghost', size: 'md' });
|
||||
|
||||
export function buttonClass({
|
||||
function buttonClass({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
className = '',
|
||||
|
|
@ -75,3 +76,39 @@ export function Button({
|
|||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ButtonLink({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: LinkProps & AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link className={buttonClass({ variant, size, className })} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function ButtonAnchor({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a className={buttonClass({ variant, size, className })} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { MenuItem, MenuItems } from '@headlessui/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
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';
|
||||
const menuPanelClass = 'rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft';
|
||||
|
||||
export function Menu({
|
||||
children,
|
||||
|
|
@ -15,9 +17,50 @@ export function Menu({
|
|||
);
|
||||
}
|
||||
|
||||
export function MenuItemClass(active: boolean, tone: 'default' | 'danger' = 'default') {
|
||||
function menuItemClass(active: boolean, tone: 'default' | 'danger' = 'default') {
|
||||
if (tone === 'danger') {
|
||||
return cn('flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs text-danger', active && 'bg-danger-wash');
|
||||
}
|
||||
return cn('flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs', active ? 'bg-accent-wash text-accent' : 'text-foreground');
|
||||
}
|
||||
|
||||
export function MenuItemsSurface({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof MenuItems>) {
|
||||
return (
|
||||
<MenuItems className={cn(menuPanelClass, className)} {...props}>
|
||||
{children}
|
||||
</MenuItems>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuActionItem({
|
||||
tone = 'default',
|
||||
activeOverride = false,
|
||||
disabled = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: Omit<ComponentProps<'button'>, 'className' | 'children'> & {
|
||||
tone?: 'default' | 'danger';
|
||||
activeOverride?: boolean;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<MenuItem disabled={disabled}>
|
||||
{({ active, disabled: itemDisabled }) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={itemDisabled}
|
||||
className={cn(menuItemClass(active || activeOverride, tone), itemDisabled && 'cursor-not-allowed text-faint', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
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',
|
||||
);
|
||||
35
src/components/ui/popover.tsx
Normal file
35
src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { cn } from './cn';
|
||||
|
||||
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',
|
||||
);
|
||||
|
||||
export function PopoverTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof PopoverButton>) {
|
||||
return (
|
||||
<PopoverButton className={cn(popoverTriggerClass, className)} {...props}>
|
||||
{children}
|
||||
</PopoverButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function PopoverSurface({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof PopoverPanel>) {
|
||||
return (
|
||||
<PopoverPanel className={cn(popoverPanelClass, className)} {...props}>
|
||||
{children}
|
||||
</PopoverPanel>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import type { InputHTMLAttributes } from 'react';
|
||||
import { cn } from './cn';
|
||||
|
||||
export const rangeInputClass = cn(
|
||||
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',
|
||||
|
|
@ -8,6 +9,13 @@ export const rangeInputClass = cn(
|
|||
'[&::-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) {
|
||||
function rangeInputClassName(className?: string) {
|
||||
return cn(rangeInputClass, className);
|
||||
}
|
||||
|
||||
export function RangeInput({
|
||||
className,
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input type="range" className={rangeInputClassName(className)} {...props} />;
|
||||
}
|
||||
|
|
@ -5,29 +5,32 @@ import type { ComponentProps } from 'react';
|
|||
import { cn } from './cn';
|
||||
export { segmentedButtonClass, segmentedGroupClass } from './segmented-control';
|
||||
|
||||
export const listboxButtonClass =
|
||||
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';
|
||||
|
||||
export const listboxToolbarButtonClass =
|
||||
const listboxToolbarButtonClass =
|
||||
'inline-flex items-center rounded-md border border-line bg-surface px-2 py-1 text-xs text-foreground hover:border-accent-line hover:bg-accent-wash hover:text-accent transition-colors duration-fast ease-standard';
|
||||
|
||||
export const listboxPanelClass =
|
||||
const listboxPopoverButtonClass =
|
||||
'inline-flex items-center rounded-md text-foreground hover:bg-accent-wash hover:text-accent focus:outline-none transition-colors duration-fast ease-standard';
|
||||
|
||||
const listboxPanelClass =
|
||||
'z-50 max-h-60 overflow-y-auto overscroll-contain rounded-md bg-surface p-1 shadow-elev-2 ring-1 ring-line focus:outline-none';
|
||||
|
||||
export const listboxOptionsClass =
|
||||
const listboxOptionsClass =
|
||||
cn(listboxPanelClass, 'w-[var(--button-width)] [--anchor-gap:0.25rem]');
|
||||
|
||||
export const listboxCompactOptionsClass =
|
||||
const listboxCompactOptionsClass =
|
||||
'z-50 min-w-[8rem] rounded-md bg-surface p-1 shadow-elev-2 ring-1 ring-line focus:outline-none [--anchor-gap:0.25rem]';
|
||||
|
||||
export const listboxOptionClass = (active: boolean, selected = false, inset: 'check' | 'none' = 'check') =>
|
||||
const listboxOptionClass = (active: boolean, selected = false, inset: 'check' | 'none' = 'check') =>
|
||||
cn(
|
||||
'relative cursor-pointer select-none rounded-sm py-1.5 text-sm',
|
||||
inset === 'check' ? 'pl-9 pr-3' : 'px-2.5',
|
||||
selected ? 'bg-accent text-background font-medium' : active ? 'bg-accent-wash text-foreground' : 'text-foreground',
|
||||
);
|
||||
|
||||
export const listboxCompactOptionClass = (active: boolean, selected = false) =>
|
||||
const listboxCompactOptionClass = (active: boolean, selected = false) =>
|
||||
cn(
|
||||
'relative cursor-pointer select-none rounded-sm px-2 py-1 text-xs',
|
||||
active
|
||||
|
|
@ -43,10 +46,12 @@ export function SharedListboxButton({
|
|||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof ListboxButton> & {
|
||||
tone?: 'default' | 'toolbar' | 'unstyled';
|
||||
tone?: 'default' | 'toolbar' | 'popover' | 'unstyled';
|
||||
}) {
|
||||
const baseClass = tone === 'toolbar'
|
||||
? listboxToolbarButtonClass
|
||||
: tone === 'popover'
|
||||
? listboxPopoverButtonClass
|
||||
: tone === 'unstyled'
|
||||
? ''
|
||||
: listboxButtonClass;
|
||||
|
|
|
|||
Loading…
Reference in a new issue