Redesign: shared UI design system + app-wide migration (#96)

* phase 0: token foundation

* phase 1: motion language

* phase 2: primitives and semantic tokens

* phase 3: depth and rhythm polish

* phase 4: enforce design system lint rules

* phase 5: split ui primitives into modules

* phase 7: refactor app surfaces to ui layer

* phase 9: enforce ui architecture imports

* phase 10: add ui system harness

* fix compact reader auth control

* fix pdf loader flash

* Converge sidebar and reader controls

* refactor: remove initial loader state and related logic from PDFViewerPage

* Remove legacy UI shim imports

* Converge modal and drawer frames

* Migrate secondary modals to shared frame

* Move settings modal onto shared frame

* Use shared cards in audiobook settings

* Converge choice and popover surfaces

* Converge reader navigation buttons

* Refactor UI components to use consistent button and icon styles across the application

* refactor(ui): unify button usage in settings, admin, and doclist components

Replace native button elements with shared Button, ChoiceTile, and IconButton
components for consistent UI behavior and styling. Update classNames and
props to match new component APIs. Adjust FinderSidebar to use utility
function for conditional class merging.

* feat(ui): introduce shared Listbox components for unified select and dropdown styling

Replace direct usage of Headless UI Listbox primitives with new SharedListboxButton,
SharedListboxOption, and SharedListboxOptions components across AudiobookExportModal,
FinderToolbar, VoicesControlBase, and select UI. Refactor related imports and classNames
to centralize dropdown styling and logic. Simplify UserMenu button markup for improved
consistency.

This change consolidates dropdown/select UI patterns, reduces duplication, and
improves maintainability by providing a single source of truth for Listbox styling
and behavior.

* 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.

* feat(ui): redesign range input with dynamic progress styling and improved accessibility

Revamp the range input component to support dynamic progress indication using CSS custom properties and linear gradients. Add logic to compute and set the progress percentage based on current value, min, and max. Refine focus and disabled states for better accessibility and usability. Update styling for both WebKit and Mozilla engines to ensure consistent appearance. This change enhances visual feedback and modernizes the range slider UI.

* style(ui): update range input to use secondary accent color for progress

Switch range input progress styling from primary to secondary accent color
for both WebKit and Mozilla engines. Remove drop shadow from slider thumb
for a cleaner appearance. This change aligns the component with the updated
design palette and simplifies visual effects.

* refactor(app): remove unused Link imports from public pages and components

Eliminate redundant imports of the Link component from Next.js in several
public-facing pages and components. These imports were no longer in use
after recent UI refactoring and consolidation of navigation elements.
This cleanup reduces bundle size and improves code clarity.

* chore(ui): remove unused export of segmented control classes from select component

Eliminate unnecessary export statements for segmentedButtonClass and
segmentedGroupClass in the select component to streamline the module's
public API and reduce potential confusion.

* test(accessibility): improve confirm dialog test coverage and refactor media state helper

Expand accessibility tests for ConfirmDialog to assert dialog semantics,
ARIA attributes, and visible destructive actions using test IDs. Refactor
expectMediaState helper to check both UI control state and underlying
media signals for more robust playback state detection.

* refactor(ui): improve segmented control accessibility and update danger color tokens

Update SegmentedControl to support full keyboard navigation and focus management,
enhancing accessibility. Replace string indicator in Select with icon, and update
button danger variant to use new --danger-strong variable for hover states. Add
danger-strong token to Tailwind config and globals. Refine dropzone disabled
behavior, adjust focus ring for better contrast, and apply minor UI consistency
tweaks across components.

* feat(ui): convert SidebarNavLink to forwardRef component

Refactor SidebarNavLink to use React.forwardRef, enabling parent components
to access the underlying anchor element's ref. Update prop typing and
function signature accordingly for improved composability and integration
with higher-order components.
This commit is contained in:
Richard R 2026-06-01 16:17:12 -06:00 committed by GitHub
parent 3d0367bc5b
commit f2c7760381
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 3367 additions and 2582 deletions

View file

@ -19,6 +19,103 @@ const LOGGER_RECEIVER_SELECTOR =
const SERVER_LOGGER_CALL_SELECTOR = `:matches(${STATIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR},${DYNAMIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR})`; const SERVER_LOGGER_CALL_SELECTOR = `:matches(${STATIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR},${DYNAMIC_LOGGER_CALL_SELECTOR}${LOGGER_RECEIVER_SELECTOR})`;
const NEXT_RESPONSE_ERROR_JSON_SELECTOR = const NEXT_RESPONSE_ERROR_JSON_SELECTOR =
"CallExpression[callee.type='MemberExpression'][callee.object.name='NextResponse'][callee.property.name='json'][arguments.0.type='ObjectExpression']:has(Property[key.name='error'])"; "CallExpression[callee.type='MemberExpression'][callee.object.name='NextResponse'][callee.property.name='json'][arguments.0.type='ObjectExpression']:has(Property[key.name='error'])";
const APP_DESIGN_SYSTEM_FILES = [
"src/app/(app)/**/*.{ts,tsx}",
"src/components/**/*.{ts,tsx}",
];
const UI_ARCHITECTURE_FILES = [
"src/app/(app)/**/*.{ts,tsx}",
"src/components/auth/**/*.{ts,tsx}",
"src/components/doclist/**/*.{ts,tsx}",
"src/components/documents/DocumentUploader.tsx",
"src/components/documents/DocumentSelectionModal.tsx",
"src/components/documents/DexieMigrationModal.tsx",
"src/components/documents/ZoomControl.tsx",
"src/components/player/**/*.{ts,tsx}",
"src/components/reader/**/*.{ts,tsx}",
];
const COMPUTE_CORE_IMPORT_PATTERNS = [
{
group: [
"@openreader/compute-core/*",
"!@openreader/compute-core/local-runtime",
"!@openreader/compute-core/types",
"!@openreader/compute-core/api-contracts",
],
message:
"Use '@openreader/compute-core' root imports for light APIs. Allowed subpaths are '@openreader/compute-core/local-runtime', '@openreader/compute-core/types', and '@openreader/compute-core/api-contracts'.",
},
];
const UI_ARCHITECTURE_IMPORT_PATHS = [
{
name: "@/components/formPrimitives",
message:
"Use '@/components/ui' primitives; formPrimitives has been removed.",
},
{
name: "@/components/ui/buttonPrimitives",
message:
"Use '@/components/ui' button exports; buttonPrimitives has been removed.",
},
{
name: "@headlessui/react",
importNames: ["Button", "Input"],
message:
"Use app UI Button/Input primitives; Headless UI should be reserved for structural primitives such as Menu, Listbox, Popover, Dialog, and Transition.",
},
];
const RESTRICTED_APP_CLASS_PATTERNS = [
{
selector: "Literal[value=/(bg|text|border|ring)-(gray|slate|zinc|neutral|stone|red|orange|yellow|amber|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\\d/]",
message:
"Use semantic design-system tokens instead of literal Tailwind palette color classes in app surfaces.",
},
{
selector: "TemplateElement[value.raw=/(bg|text|border|ring)-(gray|slate|zinc|neutral|stone|red|orange|yellow|amber|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\\d/]",
message:
"Use semantic design-system tokens instead of literal Tailwind palette color classes in app surfaces.",
},
{
selector: "Literal[value=/transition-all/]",
message:
"Use scoped transition utilities; transition-all is banned by the app design system.",
},
{
selector: "TemplateElement[value.raw=/transition-all/]",
message:
"Use scoped transition utilities; transition-all is banned by the app design system.",
},
{
selector: "Literal[value=/(bg|text|border|from|via|to)-(accent|background|foreground|base|offbase|muted|secondary-accent|surface|surface-solid|surface-sunken|line|line-soft|line-strong|soft|faint|accent-wash|accent-line|accent-strong|danger|danger-wash)\\/\\d/]",
message:
"Do not use Tailwind alpha modifiers on theme-token colors; use dedicated wash/semantic tokens.",
},
{
selector: "TemplateElement[value.raw=/(bg|text|border|from|via|to)-(accent|background|foreground|base|offbase|muted|secondary-accent|surface|surface-solid|surface-sunken|line|line-soft|line-strong|soft|faint|accent-wash|accent-line|accent-strong|danger|danger-wash)\\/\\d/]",
message:
"Do not use Tailwind alpha modifiers on theme-token colors; use dedicated wash/semantic tokens.",
},
{
selector: "Literal[value=/rounded-\\[[^\\]]*px\\]/]",
message:
"Use the app radius scale instead of arbitrary rounded-[…px] utilities.",
},
{
selector: "TemplateElement[value.raw=/rounded-\\[[^\\]]*px\\]/]",
message:
"Use the app radius scale instead of arbitrary rounded-[…px] utilities.",
},
{
selector: "Literal[value=/duration-\\[[^\\]]*ms\\]/]",
message:
"Use the shared motion duration tokens instead of arbitrary duration-[…ms] utilities.",
},
{
selector: "TemplateElement[value.raw=/duration-\\[[^\\]]*ms\\]/]",
message:
"Use the shared motion duration tokens instead of arbitrary duration-[…ms] utilities.",
},
];
const eslintConfig = [ const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"), ...compat.extends("next/core-web-vitals", "next/typescript"),
@ -27,18 +124,25 @@ const eslintConfig = [
"no-restricted-imports": [ "no-restricted-imports": [
"error", "error",
{ {
patterns: [ patterns: COMPUTE_CORE_IMPORT_PATTERNS,
{ },
group: [ ],
"@openreader/compute-core/*", },
"!@openreader/compute-core/local-runtime", },
"!@openreader/compute-core/types", {
"!@openreader/compute-core/api-contracts", files: APP_DESIGN_SYSTEM_FILES,
], rules: {
message: "no-restricted-syntax": ["error", ...RESTRICTED_APP_CLASS_PATTERNS],
"Use '@openreader/compute-core' root imports for light APIs. Allowed subpaths are '@openreader/compute-core/local-runtime', '@openreader/compute-core/types', and '@openreader/compute-core/api-contracts'.", },
}, },
], {
files: UI_ARCHITECTURE_FILES,
rules: {
"no-restricted-imports": [
"error",
{
paths: UI_ARCHITECTURE_IMPORT_PATHS,
patterns: COMPUTE_CORE_IMPORT_PATTERNS,
}, },
], ],
}, },

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import Link from 'next/link';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { EPUBViewer } from '@/components/views/EPUBViewer'; import { EPUBViewer } from '@/components/views/EPUBViewer';
@ -20,6 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
import { ButtonLink } from '@/components/ui';
import { useEpubDocument } from './useEpubDocument'; import { useEpubDocument } from './useEpubDocument';
export default function EPUBPage() { export default function EPUBPage() {
@ -170,16 +170,13 @@ export default function EPUBPage() {
if (error) { if (error) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen"> <div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p> <p className="text-danger mb-4">{error}</p>
<Link <ButtonLink href="/app" variant="secondary" size="md" className="gap-2">
href="/app" <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
>
<svg className="w-4 h-4 mr-2 text-muted" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
Back to Documents Back to Documents
</Link> </ButtonLink>
</div> </div>
); );
} }
@ -188,16 +185,12 @@ export default function EPUBPage() {
<> <>
<Header <Header
left={ left={
<Link <ButtonLink href="/app" variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
href="/app" <svg className="w-3 h-3" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
aria-label="Back to documents"
>
<svg className="w-3 h-3 mr-2" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
Documents Documents
</Link> </ButtonLink>
} }
title={isLoading ? 'Loading…' : (currDocName || '')} title={isLoading ? 'Loading…' : (currDocName || '')}
right={ right={
@ -242,7 +235,7 @@ export default function EPUBPage() {
/> />
)} )}
{isAtLimit ? ( {isAtLimit ? (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar> <div className="sticky bottom-0 z-30 w-full border-t border-line-soft bg-surface" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10"> <div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10">
<RateLimitPauseButton /> <RateLimitPauseButton />
<RateLimitBanner /> <RateLimitBanner />

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import Link from 'next/link';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton';
import { HTMLViewer } from '@/components/views/HTMLViewer'; import { HTMLViewer } from '@/components/views/HTMLViewer';
@ -18,6 +17,7 @@ import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
import { ButtonLink } from '@/components/ui';
import type { TTSAudiobookChapter } from '@/types/tts'; import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client'; import type { AudiobookGenerationSettings } from '@/types/client';
import { useHtmlDocument } from './useHtmlDocument'; import { useHtmlDocument } from './useHtmlDocument';
@ -157,16 +157,13 @@ export default function HTMLPage() {
if (error) { if (error) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen"> <div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p> <p className="text-danger mb-4">{error}</p>
<Link <ButtonLink href="/app" variant="secondary" size="md" className="gap-2">
href="/app" <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
>
<svg className="w-4 h-4 mr-2" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
Back to Documents Back to Documents
</Link> </ButtonLink>
</div> </div>
); );
} }
@ -175,16 +172,12 @@ export default function HTMLPage() {
<> <>
<Header <Header
left={ left={
<Link <ButtonLink href="/app" variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
href="/app" <svg className="w-3 h-3" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
aria-label="Back to documents"
>
<svg className="w-3 h-3 mr-2" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
Documents Documents
</Link> </ButtonLink>
} }
title={isLoading ? 'Loading…' : (currDocName || '')} title={isLoading ? 'Loading…' : (currDocName || '')}
right={ right={
@ -233,7 +226,7 @@ export default function HTMLPage() {
/> />
)} )}
{isAtLimit ? ( {isAtLimit ? (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar> <div className="sticky bottom-0 z-30 w-full border-t border-line-soft bg-surface" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10"> <div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10">
<RateLimitPauseButton /> <RateLimitPauseButton />
<RateLimitBanner /> <RateLimitBanner />

View file

@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { Providers } from '@/app/providers'; import { Providers } from '@/app/providers';
import { AppMain, AppShell } from '@/components/layout';
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@ -32,9 +33,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
allowAnonymousAuthSessions={allowAnonymousAuthSessions} allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled} githubAuthEnabled={githubAuthEnabled}
> >
<div className="app-shell h-dvh flex flex-col bg-background overflow-hidden"> <AppShell>
<main className="flex-1 min-h-0 flex flex-col">{children}</main> <AppMain>{children}</AppMain>
</div> </AppShell>
<Toaster <Toaster
toastOptions={{ toastOptions={{
style: { style: {

View file

@ -2,7 +2,6 @@
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react'; import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
import { DocumentSettings } from '@/components/documents/DocumentSettings'; import { DocumentSettings } from '@/components/documents/DocumentSettings';
@ -20,6 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
import { Button, ButtonLink } from '@/components/ui';
import { import {
FORCE_REPARSE_CONFIRM_MESSAGE, FORCE_REPARSE_CONFIRM_MESSAGE,
FORCE_REPARSE_CONFIRM_TEXT, FORCE_REPARSE_CONFIRM_TEXT,
@ -251,17 +251,13 @@ export default function PDFViewerPage() {
if (error) { if (error) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen"> <div className="flex flex-col items-center justify-center min-h-screen">
<p className="text-red-500 mb-4">{error}</p> <p className="text-danger mb-4">{error}</p>
<Link <ButtonLink href="/app" onClick={handleBackToDocuments} variant="secondary" size="md" className="gap-2">
href="/app" <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
onClick={handleBackToDocuments}
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
>
<svg className="w-4 h-4 mr-2 text-muted" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
Back to Documents Back to Documents
</Link> </ButtonLink>
</div> </div>
); );
} }
@ -308,67 +304,68 @@ export default function PDFViewerPage() {
: (isMerging ? 'Stage: merge' : 'Stage: infer')); : (isMerging ? 'Stage: merge' : 'Stage: infer'));
return ( return (
<div className="h-full w-full bg-base"> <div className="h-full w-full bg-surface">
<div className={`mx-auto flex h-full items-center px-4 py-6 transition-all duration-300 ease-out ${showDetailedParseLoader ? 'max-w-lg' : 'max-w-md'}`}> <div className={`mx-auto flex h-full items-center px-4 py-6 transition duration-slow ease-standard ${showDetailedParseLoader ? 'max-w-lg' : 'max-w-md'}`}>
{showDetailedParseLoader ? ( {showDetailedParseLoader ? (
<div className="w-full rounded-xl border border-offbase bg-offbase/95 shadow-sm overflow-hidden"> <div className="w-full rounded-lg border border-line bg-surface-sunken shadow-elev-1 overflow-hidden">
<div className="h-1 bg-[linear-gradient(90deg,var(--accent),transparent_80%)]" /> <div className="h-1 bg-[linear-gradient(90deg,var(--accent),transparent_80%)]" />
<div className="p-3.5 sm:p-4"> <div className="p-3.5 sm:p-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="inline-flex items-center gap-2 rounded-md border border-offbase bg-base/70 px-2.5 py-1"> <div className="inline-flex items-center gap-2 rounded-md border border-line bg-surface-solid px-2.5 py-1">
<LoadingSpinner className="h-3.5 w-3.5 text-accent" /> <LoadingSpinner className="h-3.5 w-3.5 text-accent" />
<span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted">PDF Layout Parse</span> <span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-soft">PDF Layout Parse</span>
</div> </div>
<p className="text-sm font-semibold text-foreground">{statusText}</p> <p className="text-sm font-semibold text-foreground">{statusText}</p>
</div> </div>
<div className="mt-3 rounded-lg border border-offbase bg-base/75 p-2.5"> <div className="mt-3 rounded-lg border border-line bg-surface-solid p-2.5">
<div className="mb-1.5 flex items-end justify-between gap-2"> <div className="mb-1.5 flex items-end justify-between gap-2">
<p className="text-[11px] font-semibold text-foreground"> <p className="text-[11px] font-semibold text-foreground">
{hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'} {hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'}
</p> </p>
<p className="text-[10px] text-muted">{stageLabel}</p> <p className="text-[10px] text-soft">{stageLabel}</p>
</div> </div>
<div className="h-2 w-full rounded-full bg-offbase overflow-hidden"> <div className="h-2 w-full rounded-full bg-surface-sunken overflow-hidden">
<div <div
className="h-full bg-accent transition-all duration-300 ease-out" className="h-full bg-accent transition duration-slow ease-standard"
style={{ width: `${hasMeasuredProgress ? progressPercent : 6}%` }} style={{ width: `${hasMeasuredProgress ? progressPercent : 6}%` }}
/> />
</div> </div>
<p className="mt-1.5 text-[10px] text-muted"> <p className="mt-1.5 text-[10px] text-soft">
{hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : statusSubText} {hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : statusSubText}
</p> </p>
</div> </div>
{!isLoading && parseUiState === 'failed' ? ( {!isLoading && parseUiState === 'failed' ? (
<div className="mt-3 flex justify-start"> <div className="mt-3 flex justify-start">
<button <Button
type="button" type="button"
onClick={requestForceReparse} onClick={requestForceReparse}
className="inline-flex items-center rounded-md border border-offbase bg-base px-3 py-1.5 text-xs font-medium text-foreground hover:text-accent transition-colors" variant="secondary"
size="sm"
> >
Retry Parse Retry Parse
</button> </Button>
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
) : ( ) : (
<div className="w-full rounded-xl border border-offbase bg-offbase/95 p-4 shadow-sm transition-all duration-300 ease-out overflow-hidden"> <div className="w-full rounded-lg border border-line bg-surface-sunken p-4 shadow-elev-1 transition duration-slow ease-standard overflow-hidden">
<div className="h-0.5 -mx-4 -mt-4 mb-3 bg-[linear-gradient(90deg,var(--accent),transparent_75%)]" /> <div className="h-0.5 -mx-4 -mt-4 mb-3 bg-[linear-gradient(90deg,var(--accent),transparent_75%)]" />
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-semibold text-foreground">{compactLabel}</p> <p className="text-sm font-semibold text-foreground">{compactLabel}</p>
<p className="mt-1 text-xs text-muted">{compactSubLabel}</p> <p className="mt-1 text-xs text-soft">{compactSubLabel}</p>
</div> </div>
<span className="inline-flex items-center justify-center rounded-md border border-offbase bg-base p-1.5"> <span className="inline-flex items-center justify-center rounded-md border border-line bg-surface p-1.5">
<LoadingSpinner className="h-3.5 w-3.5 text-accent" /> <LoadingSpinner className="h-3.5 w-3.5 text-accent" />
</span> </span>
</div> </div>
<div className="mt-3 grid grid-cols-3 gap-1.5"> <div className="mt-3 grid grid-cols-3 gap-1.5">
<span className="h-1.5 rounded-full bg-accent/30 animate-pulse" /> <span className="h-1.5 rounded-full bg-accent-wash animate-pulse" />
<span className="h-1.5 rounded-full bg-accent/20 animate-pulse [animation-delay:120ms]" /> <span className="h-1.5 rounded-full bg-accent-wash animate-pulse [animation-delay:120ms]" />
<span className="h-1.5 rounded-full bg-accent/15 animate-pulse [animation-delay:220ms]" /> <span className="h-1.5 rounded-full bg-accent-wash animate-pulse [animation-delay:220ms]" />
</div> </div>
</div> </div>
)} )}
@ -381,17 +378,12 @@ export default function PDFViewerPage() {
<> <>
<Header <Header
left={ left={
<Link <ButtonLink href="/app" onClick={handleBackToDocuments} variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
href="/app" <svg className="w-3 h-3" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
onClick={handleBackToDocuments}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
aria-label="Back to documents"
>
<svg className="w-3 h-3 mr-2" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
Documents Documents
</Link> </ButtonLink>
} }
title={isLoading ? 'Loading…' : (currDocName || '')} title={isLoading ? 'Loading…' : (currDocName || '')}
right={ right={
@ -440,7 +432,7 @@ export default function PDFViewerPage() {
/> />
)} )}
{isAtLimit ? ( {isAtLimit ? (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar> <div className="sticky bottom-0 z-30 w-full border-t border-line-soft bg-surface" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10"> <div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10">
<RateLimitPauseButton /> <RateLimitPauseButton />
<RateLimitBanner /> <RateLimitBanner />

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, Suspense } from 'react'; import { useState, useEffect, Suspense } from 'react';
import { Button, Input } from '@headlessui/react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { getAuthClient } from '@/lib/client/auth-client'; import { getAuthClient } from '@/lib/client/auth-client';
@ -10,7 +9,7 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { showPrivacyModal } from '@/components/PrivacyModal'; import { showPrivacyModal } from '@/components/PrivacyModal';
import { GithubIcon } from '@/components/icons/Icons'; import { GithubIcon } from '@/components/icons/Icons';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { Button, Field, Input, Surface } from '@/components/ui';
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) { function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -120,11 +119,11 @@ function SignInContent() {
<SessionExpiredLoader setSessionExpired={setSessionExpired} /> <SessionExpiredLoader setSessionExpired={setSessionExpired} />
</Suspense> </Suspense>
<div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6"> <Surface elevation="3" className="w-full max-w-md p-6">
<h1 className="text-xl font-semibold text-foreground"> <h1 className="text-xl font-semibold text-foreground">
{sessionExpired ? 'Session Expired' : 'Connect Account'} {sessionExpired ? 'Session Expired' : 'Connect Account'}
</h1> </h1>
<p className="text-sm text-muted mt-1"> <p className="text-sm text-soft mt-1">
{sessionExpired {sessionExpired
? 'Please sign in again to continue' ? 'Please sign in again to continue'
: 'Connect an email account to sync your data across devices'} : 'Connect an email account to sync your data across devices'}
@ -132,45 +131,41 @@ function SignInContent() {
{/* Alerts */} {/* Alerts */}
{sessionExpired && ( {sessionExpired && (
<div className="mt-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg"> <div className="mt-4 p-3 bg-accent-wash border border-accent-line rounded-lg">
<p className="text-sm text-amber-700 dark:text-amber-400"> <p className="text-sm text-accent ">
Your session has expired. Please sign in again. Your session has expired. Please sign in again.
</p> </p>
</div> </div>
)} )}
{error && ( {error && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"> <div className="mt-4 p-3 bg-danger-wash border border-danger rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p> <p className="text-sm text-danger">{error}</p>
</div> </div>
)} )}
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
{/* Email */} {/* Email */}
<div> <Field label="Email">
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
<Input <Input
type="email" type="email"
value={email} value={email}
onChange={(e) => { setEmail(e.target.value); setError(null); }} onChange={(e) => { setEmail(e.target.value); setError(null); }}
placeholder="me@example.com" placeholder="me@example.com"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm controlSize="lg"
focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </Field>
{/* Password */} {/* Password */}
<div> <Field label="Password">
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
<Input <Input
type="password" type="password"
value={password} value={password}
onChange={(e) => { setPassword(e.target.value); setError(null); }} onChange={(e) => { setPassword(e.target.value); setError(null); }}
placeholder="Password" placeholder="Password"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm controlSize="lg"
focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </Field>
{/* Remember Me */} {/* Remember Me */}
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
@ -178,7 +173,7 @@ function SignInContent() {
type="checkbox" type="checkbox"
checked={rememberMe} checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)} onChange={(e) => setRememberMe(e.target.checked)}
className="rounded border-muted text-accent focus:ring-accent" className="rounded border-muted text-accent focus:ring-accent-line"
/> />
<span className="text-sm text-foreground">Remember me</span> <span className="text-sm text-foreground">Remember me</span>
</label> </label>
@ -188,7 +183,9 @@ function SignInContent() {
type="submit" type="submit"
disabled={isAnyLoading} disabled={isAnyLoading}
onClick={handleSignIn} onClick={handleSignIn}
className={buttonClass({ variant: 'primary', size: 'md', className: 'w-full hover:scale-[1.02]' })} variant="primary"
size="md"
className="w-full"
> >
{loadingEmail ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Connect'} {loadingEmail ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Connect'}
</Button> </Button>
@ -199,11 +196,9 @@ function SignInContent() {
type="button" type="button"
disabled={isAnyLoading} disabled={isAnyLoading}
onClick={handleGithubSignIn} onClick={handleGithubSignIn}
className={buttonClass({ variant="outline"
variant: 'outline', size="md"
size: 'md', className="w-full gap-2"
className: 'w-full hover:scale-[1.02] flex items-center justify-center gap-2',
})}
> >
{loadingGithub ? ( {loadingGithub ? (
<LoadingSpinner className="w-4 h-4" /> <LoadingSpinner className="w-4 h-4" />
@ -222,7 +217,9 @@ function SignInContent() {
type="button" type="button"
disabled={isAnyLoading} disabled={isAnyLoading}
onClick={handleAnonymousContinue} onClick={handleAnonymousContinue}
className={buttonClass({ variant: 'outline', size: 'md', className: 'w-full hover:scale-[1.02]' })} variant="outline"
size="md"
className="w-full"
> >
{loadingAnonymous ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Continue anonymously'} {loadingAnonymous ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Continue anonymously'}
</Button> </Button>
@ -230,16 +227,16 @@ function SignInContent() {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="mt-6 pt-4 border-t border-offbase text-center space-y-2"> <div className="mt-6 pt-4 border-t border-line-soft text-center space-y-2">
{enableUserSignups && ( {enableUserSignups && (
<p className="text-xs text-muted"> <p className="text-xs text-soft">
Don&apos;t have an account?{' '} Don&apos;t have an account?{' '}
<Link href="/signup" className="underline hover:text-foreground"> <Link href="/signup" className="underline hover:text-foreground">
Sign up Sign up
</Link> </Link>
</p> </p>
)} )}
<p className="text-xs text-muted"> <p className="text-xs text-soft">
By signing in, you agree to our{' '} By signing in, you agree to our{' '}
<button <button
onClick={() => showPrivacyModal()} onClick={() => showPrivacyModal()}
@ -249,7 +246,7 @@ function SignInContent() {
</button> </button>
</p> </p>
</div> </div>
</div> </Surface>
</div> </div>
); );
} }

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Button, Input } from '@headlessui/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { getAuthClient } from '@/lib/client/auth-client'; import { getAuthClient } from '@/lib/client/auth-client';
@ -9,7 +8,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { showPrivacyModal } from '@/components/PrivacyModal'; import { showPrivacyModal } from '@/components/PrivacyModal';
import { LoadingSpinner } from '@/components/Spinner'; 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'; import toast from 'react-hot-toast';
export default function SignUpPage() { export default function SignUpPage() {
@ -105,77 +104,69 @@ export default function SignUpPage() {
if (!enableUserSignups) { if (!enableUserSignups) {
return ( return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background"> <div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6"> <Surface elevation="3" className="w-full max-w-md p-6">
<h1 className="text-xl font-semibold text-foreground">Sign-ups unavailable</h1> <h1 className="text-xl font-semibold text-foreground">Sign-ups unavailable</h1>
<p className="text-sm text-muted mt-1"> <p className="text-sm text-soft mt-1">
New account sign-ups are currently disabled by the site administrator. New account sign-ups are currently disabled by the site administrator.
</p> </p>
<div className="mt-6 pt-4 border-t border-offbase text-center"> <div className="mt-6 pt-4 border-t border-line-soft text-center">
<p className="text-xs text-muted"> <p className="text-xs text-soft">
Already have an account?{' '} Already have an account?{' '}
<Link href="/signin" className="underline hover:text-foreground"> <Link href="/signin" className="underline hover:text-foreground">
Sign in Sign in
</Link> </Link>
</p> </p>
</div> </div>
</div> </Surface>
</div> </div>
); );
} }
const { checks, strength } = validatePassword(password); const { checks, strength } = validatePassword(password);
const strengthLabels = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong']; const strengthLabels = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'];
const strengthColors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-blue-500', 'bg-green-500']; const strengthColors = ['bg-danger', 'bg-danger', 'bg-accent', 'bg-accent', 'bg-accent'];
return ( return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background"> <div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6"> <Surface elevation="3" className="w-full max-w-md p-6">
<h1 className="text-xl font-semibold text-foreground">Sign Up</h1> <h1 className="text-xl font-semibold text-foreground">Sign Up</h1>
<p className="text-sm text-muted mt-1">Create your account to get started</p> <p className="text-sm text-soft mt-1">Create your account to get started</p>
{error && ( {error && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg"> <div className="mt-4 p-3 bg-danger-wash border border-danger rounded-lg">
<p className="text-sm text-red-700 dark:text-red-400">{error}</p> <p className="text-sm text-danger">{error}</p>
</div> </div>
)} )}
<div className="mt-6 space-y-4"> <div className="mt-6 space-y-4">
{/* Email */} {/* Email */}
<div> <Field label="Email">
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
<Input <Input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="me@example.com" placeholder="me@example.com"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm controlSize="lg"
focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </Field>
{/* Password */} {/* Password */}
<div> <Field label="Password">
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
<div className="relative"> <div className="relative">
<Input <Input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Password" placeholder="Password"
className="w-full rounded-lg bg-background py-2 px-3 pr-10 text-foreground shadow-sm controlSize="lg"
focus:outline-none focus:ring-2 focus:ring-accent" className="pr-10"
/> />
<button <IconButton
type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className={buttonClass({ className="absolute right-2 top-1/2 h-7 w-7 -translate-y-1/2"
variant: 'ghost',
size: 'icon',
className: 'absolute right-2 top-1/2 -translate-y-1/2 h-7 w-7 text-muted',
})}
> >
{showPassword ? '👁️' : '👁️‍🗨️'} {showPassword ? '👁️' : '👁️‍🗨️'}
</button> </IconButton>
</div> </div>
{/* Password Strength */} {/* Password Strength */}
@ -185,17 +176,17 @@ export default function SignUpPage() {
{[0, 1, 2, 3, 4].map((i) => ( {[0, 1, 2, 3, 4].map((i) => (
<div <div
key={i} key={i}
className={`h-1 flex-1 rounded ${i < strength ? strengthColors[strength - 1] : 'bg-offbase' className={`h-1 flex-1 rounded ${i < strength ? strengthColors[strength - 1] : 'bg-surface-sunken'
}`} }`}
/> />
))} ))}
</div> </div>
<p className={`text-xs ${strength >= 3 ? 'text-green-600' : 'text-red-600'}`}> <p className={`text-xs ${strength >= 3 ? 'text-accent' : 'text-danger'}`}>
{strengthLabels[strength - 1] || 'Very Weak'} {strengthLabels[strength - 1] || 'Very Weak'}
</p> </p>
<div className="text-xs space-y-0.5 text-muted"> <div className="text-xs space-y-0.5 text-soft">
{Object.entries(checks).map(([key, passed]) => ( {Object.entries(checks).map(([key, passed]) => (
<div key={key} className={`flex items-center gap-1 ${passed ? 'text-green-600' : ''}`}> <div key={key} className={`flex items-center gap-1 ${passed ? 'text-accent' : ''}`}>
<span>{passed ? '✓' : '○'}</span> <span>{passed ? '✓' : '○'}</span>
<span> <span>
{key === 'length' && 'At least 8 characters'} {key === 'length' && 'At least 8 characters'}
@ -209,46 +200,46 @@ export default function SignUpPage() {
</div> </div>
</div> </div>
)} )}
</div> </Field>
{/* Confirm Password */} {/* Confirm Password */}
<div> <Field label="Confirm Password">
<label className="block text-sm font-medium text-foreground mb-1">Confirm Password</label>
<Input <Input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={passwordConfirmation} value={passwordConfirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)} onChange={(e) => setPasswordConfirmation(e.target.value)}
placeholder="Confirm Password" placeholder="Confirm Password"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm controlSize="lg"
focus:outline-none focus:ring-2 focus:ring-accent"
/> />
{passwordConfirmation && password && ( {passwordConfirmation && password && (
<p className={`text-xs mt-1 ${password === passwordConfirmation ? 'text-green-600' : 'text-red-600'}`}> <p className={`text-xs mt-1 ${password === passwordConfirmation ? 'text-accent' : 'text-danger'}`}>
{password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'} {password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'}
</p> </p>
)} )}
</div> </Field>
{/* Sign Up Button */} {/* Sign Up Button */}
<Button <Button
type="submit" type="submit"
disabled={loading} disabled={loading}
onClick={handleSignUp} onClick={handleSignUp}
className={buttonClass({ variant: 'primary', size: 'md', className: 'w-full hover:scale-[1.02]' })} variant="primary"
size="md"
className="w-full"
> >
{loading ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Create Account'} {loading ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Create Account'}
</Button> </Button>
</div> </div>
{/* Footer */} {/* Footer */}
<div className="mt-6 pt-4 border-t border-offbase text-center space-y-2"> <div className="mt-6 pt-4 border-t border-line-soft text-center space-y-2">
<p className="text-xs text-muted"> <p className="text-xs text-soft">
Already have an account?{' '} Already have an account?{' '}
<Link href="/signin" className="underline hover:text-foreground"> <Link href="/signin" className="underline hover:text-foreground">
Sign in Sign in
</Link> </Link>
</p> </p>
<p className="text-xs text-muted"> <p className="text-xs text-soft">
By creating an account, you agree to our{' '} By creating an account, you agree to our{' '}
<button <button
onClick={() => showPrivacyModal()} onClick={() => showPrivacyModal()}
@ -258,7 +249,7 @@ export default function SignUpPage() {
</button> </button>
</p> </p>
</div> </div>
</div> </Surface>
</div> </div>
); );
} }

View file

@ -1,7 +1,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc'; import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc';
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { ButtonAnchor, ButtonLink } from '@/components/ui';
import './public.css'; import './public.css';
export default async function PublicLayout({ children }: { children: ReactNode }) { export default async function PublicLayout({ children }: { children: ReactNode }) {
@ -42,12 +42,8 @@ export default async function PublicLayout({ children }: { children: ReactNode }
GitHub GitHub
</a> </a>
<span className="public-nav-divider" aria-hidden="true" /> <span className="public-nav-divider" aria-hidden="true" />
<Link href="/signin" className={buttonClass({ variant: 'ghost', size: 'sm' })}> <ButtonLink href="/signin" variant="ghost" size="sm">Sign in</ButtonLink>
Sign in <ButtonLink href="/app" variant="primary" size="sm">Open app</ButtonLink>
</Link>
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'sm' })}>
Open app
</Link>
</nav> </nav>
</header> </header>
</div> </div>
@ -70,18 +66,11 @@ export default async function PublicLayout({ children }: { children: ReactNode }
</p> </p>
<div className="public-footer-cta"> <div className="public-footer-cta">
{enableUserSignups ? ( {enableUserSignups ? (
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'sm' })}> <ButtonLink href="/signup" variant="outline" size="sm">Create account</ButtonLink>
Create account
</Link>
) : null} ) : null}
<a <ButtonAnchor href="https://github.com/richardr1126/openreader" target="_blank" rel="noopener noreferrer" variant="ghost" size="sm">
href="https://github.com/richardr1126/openreader"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'ghost', size: 'sm' })}
>
Star on GitHub Star on GitHub
</a> </ButtonAnchor>
</div> </div>
</div> </div>

View file

@ -1,8 +1,7 @@
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link';
import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc'; import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc';
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { ButtonAnchor, ButtonLink } from '@/components/ui';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Open Source Read-Along Workspace', title: 'Open Source Read-Along Workspace',
@ -98,24 +97,13 @@ export default async function LandingPage() {
</p> </p>
<div className="public-actions"> <div className="public-actions">
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'lg' })}> <ButtonLink href="/app" variant="primary" size="lg">Open the reader</ButtonLink>
Open the reader
</Link>
{enableUserSignups ? ( {enableUserSignups ? (
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'lg' })}> <ButtonLink href="/signup" variant="outline" size="lg">Create account</ButtonLink>
Create account
</Link>
) : ( ) : (
<Link href="/signin" className={buttonClass({ variant: 'outline', size: 'lg' })}> <ButtonLink href="/signin" variant="outline" size="lg">Sign in</ButtonLink>
Sign in
</Link>
)} )}
<Link <ButtonAnchor href="https://docs.openreader.richardr.dev/" target="_blank" rel="noopener noreferrer" variant="ghost" size="lg">Read the docs </ButtonAnchor>
href="https://docs.openreader.richardr.dev/"
className={buttonClass({ variant: 'ghost', size: 'lg' })}
>
Read the docs
</Link>
</div> </div>
<div className="public-formats" aria-label="Supported formats"> <div className="public-formats" aria-label="Supported formats">
@ -316,22 +304,12 @@ export default async function LandingPage() {
external compute worker. Every piece is yours to host. external compute worker. Every piece is yours to host.
</p> </p>
<div className="public-actions"> <div className="public-actions">
<a <ButtonAnchor href="https://github.com/richardr1126/openreader#readme" target="_blank" rel="noopener noreferrer" variant="primary" size="lg">
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'primary', size: 'lg' })}
>
View the repository View the repository
</a> </ButtonAnchor>
<a <ButtonAnchor href="https://docs.openreader.richardr.dev/docker-quick-start" target="_blank" rel="noopener noreferrer" variant="outline" size="lg">
href="https://docs.openreader.richardr.dev/docker-quick-start"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'outline', size: 'lg' })}
>
Deployment guides Deployment guides
</a> </ButtonAnchor>
</div> </div>
</div> </div>

View file

@ -1,7 +1,6 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { ButtonAnchor, ButtonLink } from '@/components/ui';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Privacy & Data Usage', title: 'Privacy & Data Usage',
@ -181,25 +180,13 @@ export default async function PrivacyPage() {
want full infrastructure control. want full infrastructure control.
</p> </p>
<div className="policy-actions"> <div className="policy-actions">
<a <ButtonAnchor href="https://github.com/richardr1126/openreader/issues" target="_blank" rel="noopener noreferrer" variant="primary" size="md">
href="https://github.com/richardr1126/openreader/issues"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'primary', size: 'md' })}
>
Open an Issue Open an Issue
</a> </ButtonAnchor>
<a <ButtonAnchor href="https://github.com/richardr1126/openreader#readme" target="_blank" rel="noopener noreferrer" variant="outline" size="md">
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'outline', size: 'md' })}
>
Self-Hosting Guide Self-Hosting Guide
</a> </ButtonAnchor>
<Link href="/?redirect=false" className={buttonClass({ variant: 'ghost', size: 'md' })}> <ButtonLink href="/?redirect=false" variant="ghost" size="md">Back to landing</ButtonLink>
Back to landing
</Link>
</div> </div>
</section> </section>
</div> </div>

View file

@ -10,7 +10,6 @@ import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state'
import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { isValidDocumentId } from '@/lib/server/documents/blobstore';
import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state'; import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state';
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing'; import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3'; import { isS3Configured } from '@/lib/server/storage/s3';
import { createRequestLogger, hashForLog } from '@/lib/server/logger'; import { createRequestLogger, hashForLog } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response'; import { errorResponse } from '@/lib/server/errors/next-response';
@ -159,7 +158,6 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
? requestedOpIdRaw.trim() ? requestedOpIdRaw.trim()
: null; : null;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const storageUserId = authCtxOrRes.userId; const storageUserId = authCtxOrRes.userId;
const storageUserIdHash = hashForLog(storageUserId); const storageUserIdHash = hashForLog(storageUserId);
const allowedUserIds = [storageUserId]; const allowedUserIds = [storageUserId];

View file

@ -201,6 +201,57 @@ html.custom {
); );
} }
:root {
--shadow-rgb: 15 23 42;
--surface: var(--base);
--surface-solid: color-mix(in srgb, var(--base) 96%, var(--background));
--surface-sunken: color-mix(in srgb, var(--base) 80%, var(--background));
--line: color-mix(in srgb, var(--offbase) 70%, transparent);
--line-soft: color-mix(in srgb, var(--offbase) 45%, transparent);
--line-strong: var(--offbase);
--soft: color-mix(in srgb, var(--foreground) 74%, var(--muted));
--faint: color-mix(in srgb, var(--foreground) 52%, var(--muted));
--accent-wash: color-mix(in srgb, var(--accent) 12%, transparent);
--accent-line: color-mix(in srgb, var(--accent) 40%, transparent);
--accent-strong: color-mix(in srgb, var(--accent) 88%, var(--foreground));
--danger: #dc2626;
--danger-strong: #b91c1c;
--danger-wash: color-mix(in srgb, var(--danger) 12%, transparent);
--elev-1: 0 1px 2px rgb(var(--shadow-rgb) / 0.10);
--elev-2: 0 6px 16px rgb(var(--shadow-rgb) / 0.16);
--elev-3: 0 18px 45px rgb(var(--shadow-rgb) / 0.22);
--dur-fast: 120ms;
--dur-base: 180ms;
--dur-slow: 260ms;
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
}
html.light,
html.lavender,
html.rose,
html.sand,
html.sky,
html.slate {
--shadow-rgb: 15 23 42;
}
html.dark,
html.ocean,
html.forest,
html.sunset,
html.sea,
html.mint,
html.custom {
--shadow-rgb: 0 0 0;
}
body { body {
color: var(--foreground); color: var(--foreground);
background: var(--background); background: var(--background);
@ -248,6 +299,17 @@ h1, h2, h3, h4, h5, h6 {
to { opacity: 1; } to { opacity: 1; }
} }
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 1ms !important;
}
}
/* Hide react-pdf's default tiny "Loading page..." placeholder to avoid jarring layout shifts. */ /* Hide react-pdf's default tiny "Loading page..." placeholder to avoid jarring layout shifts. */
.pdf-viewer .react-pdf__message { .pdf-viewer .react-pdf__message {
display: none !important; display: none !important;

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { Transition, Button, Listbox, ListboxButton, ListboxOptions, ListboxOption, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'; import { Transition, Listbox, Menu, MenuButton } from '@headlessui/react';
import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { useTimeEstimation } from '@/hooks/useTimeEstimation';
import { ProgressPopup } from '@/components/ProgressPopup'; import { ProgressPopup } from '@/components/ProgressPopup';
import { ProgressCard } from '@/components/ProgressCard'; import { ProgressCard } from '@/components/ProgressCard';
@ -13,6 +13,7 @@ import { VoicesControlBase } from '@/components/player/VoicesControlBase';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, SharedListboxButton, SharedListboxOption, SharedListboxOptions } from '@/components/ui';
import { import {
getAudiobookStatus, getAudiobookStatus,
deleteAudiobookChapter, deleteAudiobookChapter,
@ -476,12 +477,12 @@ export function AudiobookExportModal({
<> <>
<div className="space-y-4"> <div className="space-y-4">
{!isGenerating && ( {!isGenerating && (
<div className="w-full rounded-xl border border-offbase bg-background"> <div className="w-full rounded-lg border border-line bg-background">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-offbase bg-base rounded-t-xl"> <div className="flex items-center justify-between px-4 py-3 border-b border-line-soft bg-surface rounded-t-xl">
<h4 className="text-sm font-medium text-foreground tracking-tight">Generation settings</h4> <h4 className="text-sm font-medium text-foreground tracking-tight">Generation settings</h4>
{settingsLocked && ( {settingsLocked && (
<span className="inline-flex items-center gap-1 rounded-md bg-offbase px-2 py-0.5 text-[11px] font-medium text-muted uppercase tracking-wider"> <span className="inline-flex items-center gap-1 rounded-md bg-surface-sunken px-2 py-0.5 text-[11px] font-medium text-soft uppercase tracking-wider">
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor"><path fillRule="evenodd" d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7A1.5 1.5 0 0 0 3 8.5v5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 11.5 7V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z" clipRule="evenodd" /></svg> <svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor"><path fillRule="evenodd" d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7A1.5 1.5 0 0 0 3 8.5v5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 11.5 7V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z" clipRule="evenodd" /></svg>
Locked Locked
</span> </span>
@ -490,9 +491,9 @@ export function AudiobookExportModal({
<div className="p-4"> <div className="p-4">
{isLegacyAudiobookMissingSettings && ( {isLegacyAudiobookMissingSettings && (
<div className="mb-4 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-xs text-foreground"> <div className="mb-4 rounded-lg border border-accent-line bg-accent-wash p-3 text-xs text-foreground">
<div className="font-medium">Saved generation settings not found</div> <div className="font-medium">Saved generation settings not found</div>
<div className="mt-1 text-muted"> <div className="mt-1 text-soft">
This audiobook was likely created before v1 metadata was introduced, so OpenReader can&apos;t know This audiobook was likely created before v1 metadata was introduced, so OpenReader can&apos;t know
which voice/speeds/format were used. Consider resetting this audiobook to regenerate it with which voice/speeds/format were used. Consider resetting this audiobook to regenerate it with
v1 metadata (so settings are saved for resumes across devices). v1 metadata (so settings are saved for resumes across devices).
@ -503,18 +504,18 @@ export function AudiobookExportModal({
{settingsLocked && savedSettings ? ( {settingsLocked && savedSettings ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-base p-3"> <Card className="p-3">
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Voice</div> <div className="text-[11px] uppercase tracking-wider text-soft mb-1">Voice</div>
<div className="text-sm font-medium text-foreground truncate">{savedSettings.voice}</div> <div className="text-sm font-medium text-foreground truncate">{savedSettings.voice}</div>
</div> </Card>
<div className="rounded-lg bg-base p-3"> <Card className="p-3">
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Format</div> <div className="text-[11px] uppercase tracking-wider text-soft mb-1">Format</div>
<div className="text-sm font-medium text-foreground">{savedSettings.format.toUpperCase()}</div> <div className="text-sm font-medium text-foreground">{savedSettings.format.toUpperCase()}</div>
</div> </Card>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-base p-3"> <Card className="p-3">
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Native speed</div> <div className="text-[11px] uppercase tracking-wider text-soft mb-1">Native speed</div>
<div className="text-sm font-medium text-foreground"> <div className="text-sm font-medium text-foreground">
{resolveTtsProviderModelPolicy({ {resolveTtsProviderModelPolicy({
providerRef: savedSettings.providerRef, providerRef: savedSettings.providerRef,
@ -524,13 +525,13 @@ export function AudiobookExportModal({
? `${formatSpeed(savedSettings.nativeSpeed)}x` ? `${formatSpeed(savedSettings.nativeSpeed)}x`
: 'Not supported'} : 'Not supported'}
</div> </div>
</div> </Card>
<div className="rounded-lg bg-base p-3"> <Card className="p-3">
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Post speed</div> <div className="text-[11px] uppercase tracking-wider text-soft mb-1">Post speed</div>
<div className="text-sm font-medium text-foreground">{formatSpeed(savedSettings.postSpeed)}x</div> <div className="text-sm font-medium text-foreground">{formatSpeed(savedSettings.postSpeed)}x</div>
</div> </Card>
</div> </div>
<p className="text-xs text-muted"> <p className="text-xs text-soft">
Reset the audiobook to change generation settings. Reset the audiobook to change generation settings.
</p> </p>
</div> </div>
@ -539,7 +540,7 @@ export function AudiobookExportModal({
{/* Voice & Format row */} {/* Voice & Format row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Voice</label> <label className="text-[11px] uppercase tracking-wider font-medium text-soft">Voice</label>
<VoicesControlBase <VoicesControlBase
availableVoices={availableVoices} availableVoices={availableVoices}
voice={audiobookVoice} voice={audiobookVoice}
@ -552,7 +553,7 @@ export function AudiobookExportModal({
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Format</label> <label className="text-[11px] uppercase tracking-wider font-medium text-soft">Format</label>
{chapters.length === 0 ? ( {chapters.length === 0 ? (
<Listbox <Listbox
value={format} value={format}
@ -560,44 +561,42 @@ export function AudiobookExportModal({
disabled={chapters.length > 0 || settingsLocked} disabled={chapters.length > 0 || settingsLocked}
> >
<div className="relative"> <div className="relative">
<ListboxButton className="relative cursor-pointer rounded-lg bg-base py-1.5 pl-3 pr-10 text-left text-foreground focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent w-full"> <SharedListboxButton className="bg-surface">
<span className="block truncate text-sm font-medium">{format.toUpperCase()}</span> <span className="block truncate text-sm font-medium">{format.toUpperCase()}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" /> <ChevronUpDownIcon className="h-4 w-4 text-soft" />
</span> </span>
</ListboxButton> </SharedListboxButton>
<Transition <Transition
as={Fragment} as={Fragment}
leave="transition ease-in duration-100" leave="transition ease-standard duration-fast"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions className="absolute left-0 mt-1 max-h-60 w-full overflow-auto rounded-lg bg-base py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10"> <SharedListboxOptions className="absolute left-0 mt-1 w-full">
<ListboxOption <SharedListboxOption
value="m4b" value="m4b"
className={({ active }) => inset="none"
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}` itemClassName="py-2"
}
> >
{({ selected }) => ( {({ selected }) => (
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}> <span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
M4B M4B
</span> </span>
)} )}
</ListboxOption> </SharedListboxOption>
<ListboxOption <SharedListboxOption
value="mp3" value="mp3"
className={({ active }) => inset="none"
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}` itemClassName="py-2"
}
> >
{({ selected }) => ( {({ selected }) => (
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}> <span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
MP3 MP3
</span> </span>
)} )}
</ListboxOption> </SharedListboxOption>
</ListboxOptions> </SharedListboxOptions>
</Transition> </Transition>
</div> </div>
</Listbox> </Listbox>
@ -608,9 +607,9 @@ export function AudiobookExportModal({
</div> </div>
{/* Speed controls */} {/* Speed controls */}
<div className="rounded-lg bg-base p-3 space-y-3"> <Card className="p-3 space-y-3">
{!nativeSpeedSupported && ( {!nativeSpeedSupported && (
<div className="rounded-md border border-offbase bg-background px-2 py-1.5 text-[11px] text-muted"> <div className="rounded-md border border-line bg-background px-2 py-1.5 text-[11px] text-soft">
Native model speed is not available for this model. Native model speed is not available for this model.
</div> </div>
)} )}
@ -619,48 +618,44 @@ export function AudiobookExportModal({
<> <>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Native model speed</label> <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> <span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(nativeSpeed)}x</span>
</div> </div>
<input <RangeInput
type="range"
min="0.5" min="0.5"
max="3" max="3"
step="0.1" step="0.1"
value={nativeSpeed} value={nativeSpeed}
onChange={(e) => setNativeSpeed(parseFloat(e.target.value))} onChange={(e) => setNativeSpeed(parseFloat(e.target.value))}
className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent [&::-moz-range-thumb]:border-0"
/> />
<div className="flex justify-between text-[10px] text-muted"> <div className="flex justify-between text-[10px] text-soft">
<span>0.5x</span> <span>0.5x</span>
<span>3x</span> <span>3x</span>
</div> </div>
</div> </div>
<div className="border-t border-offbase" /> <div className="border-t border-line-soft" />
</> </>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Post-generation speed</label> <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> <span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(postSpeed)}x</span>
</div> </div>
<input <RangeInput
type="range"
min="0.5" min="0.5"
max="3" max="3"
step="0.1" step="0.1"
value={postSpeed} value={postSpeed}
onChange={(e) => setPostSpeed(parseFloat(e.target.value))} onChange={(e) => setPostSpeed(parseFloat(e.target.value))}
className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent [&::-moz-range-thumb]:border-0"
/> />
<div className="flex justify-between text-[10px] text-muted"> <div className="flex justify-between text-[10px] text-soft">
<span>0.5x</span> <span>0.5x</span>
<span>3x</span> <span>3x</span>
</div> </div>
</div> </div>
</div> </Card>
</div> </div>
)} )}
@ -670,10 +665,9 @@ export function AudiobookExportModal({
<Button <Button
onClick={handleStartGeneration} onClick={handleStartGeneration}
disabled={!canGenerate} disabled={!canGenerate}
className="flex-1 inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm variant="primary"
font-medium text-background hover:bg-secondary-accent focus:outline-none size="md"
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 className="flex-1"
transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
> >
Start Generation Start Generation
</Button> </Button>
@ -682,10 +676,9 @@ export function AudiobookExportModal({
<Button <Button
onClick={handleStartGeneration} onClick={handleStartGeneration}
disabled={!canGenerate} disabled={!canGenerate}
className="flex-1 inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm variant="primary"
font-medium text-background hover:bg-secondary-accent focus:outline-none size="md"
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 className="flex-1"
transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
> >
Resume Resume
</Button> </Button>
@ -694,10 +687,8 @@ export function AudiobookExportModal({
<Button <Button
onClick={() => setShowResetConfirm(true)} onClick={() => setShowResetConfirm(true)}
disabled={isGenerating} disabled={isGenerating}
className="inline-flex justify-center rounded-lg border border-red-500 bg-transparent px-3 py-2 text-sm variant="danger"
font-medium text-red-500 hover:bg-red-500 hover:text-background focus:outline-none size="md"
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.01]"
title="Delete all generated chapters/pages for this document" title="Delete all generated chapters/pages for this document"
> >
Reset Reset
@ -708,21 +699,21 @@ export function AudiobookExportModal({
</div> </div>
)} )}
{showRegenerateHint && ( {showRegenerateHint && (
<div className="flex items-start justify-between bg-offbase border border-offbase rounded-md px-3 py-2 text-xs sm:text-sm"> <div className="flex items-start justify-between bg-surface-sunken border border-line rounded-md px-3 py-2 text-xs sm:text-sm">
<p className="text-xs sm:text-sm text-foreground"> <p className="text-xs sm:text-sm text-foreground">
TTS audio for this chapter may be cached TTS audio for this chapter may be cached
<br /> <br />
Change the TTS playback options or restart the server to force uncached regeneration Change the TTS playback options or restart the server to force uncached regeneration
</p> </p>
<Button <IconButton
onClick={() => setShowRegenerateHint(false)} onClick={() => setShowRegenerateHint(false)}
className="ml-3 p-1 rounded-md hover:bg-base hover:text-accent transition-colors" className="ml-3"
aria-label="Dismiss regenerate hint" aria-label="Dismiss regenerate hint"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</Button> </IconButton>
</div> </div>
)} )}
{/* Progress Info */} {/* Progress Info */}
@ -746,34 +737,36 @@ export function AudiobookExportModal({
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-foreground">Chapters</h4> <h4 className="text-sm font-medium text-foreground">Chapters</h4>
{isRefreshingChapters && <ClockIcon className="h-4 w-4 text-muted animate-spin" />} {isRefreshingChapters && <ClockIcon className="h-4 w-4 text-soft animate-spin" />}
</div> </div>
{displayChapters.map((chapter) => ( {displayChapters.map((chapter) => (
<div <div
key={chapter.index} key={chapter.index}
className={`flex items-center justify-between px-2 sm:px-3 py-1 sm:py-1.5 rounded-lg bg-offbase ${(regeneratingChapter === chapter.index || chapter.status === 'generating') ? 'prism-outline' : ''}`} className={`flex items-center justify-between px-2 sm:px-3 py-1 sm:py-1.5 rounded-lg bg-surface-sunken ${(regeneratingChapter === chapter.index || chapter.status === 'generating') ? 'prism-outline' : ''}`}
> >
<div className="flex items-center space-x-3 flex-1"> <div className="flex items-center space-x-3 flex-1">
{chapter.status === 'completed' ? ( {chapter.status === 'completed' ? (
<CheckCircleIcon className="h-5 w-5 text-accent" /> <CheckCircleIcon className="h-5 w-5 text-accent" />
) : onRegenerateChapter ? ( ) : onRegenerateChapter ? (
<Button <IconButton
onClick={() => handleRegenerateChapter(chapter)} onClick={() => handleRegenerateChapter(chapter)}
disabled={regeneratingChapter !== null || chapter.status === 'generating' || isGenerating} disabled={regeneratingChapter !== null || chapter.status === 'generating' || isGenerating}
className="inline-flex items-center justify-center rounded-full bg-offbase text-accent hover:bg-accent/20 p-1.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04] disabled:opacity-50 disabled:cursor-not-allowed" tone="ghost"
size="sm"
className="rounded-full bg-surface-sunken text-accent"
title={chapter.status === 'generating' ? 'Generating...' : 'Regenerate this chapter'} title={chapter.status === 'generating' ? 'Generating...' : 'Regenerate this chapter'}
> >
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index || chapter.status === 'generating' ? 'animate-spin' : ''}`} /> <RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index || chapter.status === 'generating' ? 'animate-spin' : ''}`} />
</Button> </IconButton>
) : ( ) : (
<ClockIcon className="h-5 w-5 text-muted" /> <ClockIcon className="h-5 w-5 text-soft" />
)} )}
<div className="flex flex-row flex-wrap items-center gap-1"> <div className="flex flex-row flex-wrap items-center gap-1">
<p className="text-sm font-medium text-foreground"> <p className="text-sm font-medium text-foreground">
{chapter.title} {chapter.title}
</p> </p>
<p></p> <p></p>
<p className="text-xs text-muted mt-0.5"> <p className="text-xs text-soft mt-0.5">
{chapter.status !== 'completed' && <span className="text-warning">Missing </span>}{formatDuration(chapter.duration)} {chapter.status !== 'completed' && <span className="text-warning">Missing </span>}{formatDuration(chapter.duration)}
</p> </p>
</div> </div>
@ -782,82 +775,63 @@ export function AudiobookExportModal({
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && ( {((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
<Menu as="div" className="relative inline-block text-left"> <Menu as="div" className="relative inline-block text-left">
<MenuButton <MenuButton
className="inline-flex items-center justify-center rounded-md p-1.5 hover:bg-background focus:outline-none focus-visible:ring-2 focus-visible:ring-accent text-muted hover:text-foreground transform transition-transform duration-200 ease-in-out hover:scale-[1.04]" as={IconButton}
size="sm"
title="Chapter actions" title="Chapter actions"
> >
<DotsVerticalIcon className="h-5 w-5" /> <DotsVerticalIcon className="h-5 w-5" />
</MenuButton> </MenuButton>
<Transition <Transition
as={Fragment} as={Fragment}
enter="transition ease-out duration-100" enter="transition ease-standard duration-fast"
enterFrom="transform opacity-0 scale-95" enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100" enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75" leave="transition ease-standard duration-fast"
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<MenuItems <MenuItemsSurface
anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }} anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }}
portal portal
className="w-44 rounded-md bg-background shadow-lg ring-1 ring-black/5 focus:outline-none z-[70] p-1 origin-top-right" className="z-[70] w-44 origin-top-right bg-background focus:outline-none"
> >
{chapter.status === 'completed' && ( {chapter.status === 'completed' && (
<> <>
<MenuItem> <MenuActionItem
{({ active }) => ( tone="danger"
<button onClick={() => setPendingDeleteChapter(chapter)}
onClick={() => setPendingDeleteChapter(chapter)} title="Delete this chapter"
className={`${active ? 'bg-offbase' : ''} text-red-500 group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`} >
title="Delete this chapter" <XCircleIcon className="h-4 w-4" />
> <span>Delete</span>
<XCircleIcon className="h-4 w-4" /> </MenuActionItem>
<span>Delete</span> <MenuActionItem onClick={() => handleDownloadChapter(chapter)}>
</button> <DownloadIcon className="h-4 w-4" />
)} <span>Download</span>
</MenuItem> </MenuActionItem>
<MenuItem>
{({ active }) => (
<button
onClick={() => handleDownloadChapter(chapter)}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
>
<DownloadIcon className="h-4 w-4" />
<span>Download</span>
</button>
)}
</MenuItem>
</> </>
)} )}
{regeneratingChapter === chapter.index && ( {regeneratingChapter === chapter.index && (
<MenuItem> <MenuActionItem
{({ active }) => ( tone="danger"
<button onClick={handleCancel}
onClick={handleCancel} title="Cancel this chapter regeneration"
className={`${active ? 'bg-offbase text-red-500' : 'text-red-500'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`} >
title="Cancel this chapter regeneration" <XCircleIcon className="h-4 w-4" />
> <span>Cancel</span>
<XCircleIcon className="h-4 w-4" /> </MenuActionItem>
<span>Cancel</span>
</button>
)}
</MenuItem>
)} )}
{onRegenerateChapter && !isGenerating && ( {onRegenerateChapter && !isGenerating && (
<MenuItem disabled={regeneratingChapter !== null}> <MenuActionItem
{({ active, disabled }) => ( disabled={regeneratingChapter !== null}
<button onClick={() => handleRegenerateChapter(chapter)}
onClick={() => handleRegenerateChapter(chapter)} title="Regenerate this chapter"
disabled={disabled} >
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed`} <RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
title="Regenerate this chapter" <span>{regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'}</span>
> </MenuActionItem>
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
<span>{regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'}</span>
</button>
)}
</MenuItem>
)} )}
</MenuItems> </MenuItemsSurface>
{/* end of menu items */} {/* end of menu items */}
</Transition> </Transition>
</Menu> </Menu>
@ -868,15 +842,13 @@ export function AudiobookExportModal({
</div> </div>
{bookId && !isGenerating && ( {bookId && !isGenerating && (
<div className="pt-4 border-t border-offbase"> <div className="pt-4 border-t border-line-soft">
<Button <Button
onClick={handleDownloadComplete} onClick={handleDownloadComplete}
disabled={isCombining} disabled={isCombining}
className="w-full inline-flex justify-center items-center space-x-2 rounded-lg bg-accent px-3 py-1.5 text-sm variant="primary"
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 size="md"
font-medium text-background hover:bg-secondary-accent focus:outline-none className="w-full space-x-2"
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
> >
<DownloadIcon className="h-5 w-5" /> <DownloadIcon className="h-5 w-5" />
<span>{isCombining ? 'Combining chapters...' : `Full Download (${format.toUpperCase()})`}</span> <span>{isCombining ? 'Combining chapters...' : `Full Download (${format.toUpperCase()})`}</span>
@ -888,7 +860,7 @@ export function AudiobookExportModal({
{chapters.length === 0 && !isGenerating && !isLoadingExisting && ( {chapters.length === 0 && !isGenerating && !isLoadingExisting && (
<div className="text-center"> <div className="text-center">
<p className="text-sm text-muted"> <p className="text-sm text-soft">
Audiobook settings are fixed after generation. Chapters will appear here as they are ready. Audiobook settings are fixed after generation. Chapters will appear here as they are ready.
</p> </p>
</div> </div>
@ -938,43 +910,43 @@ export function AudiobookExportModal({
function AudiobookSettingsSkeleton() { function AudiobookSettingsSkeleton() {
return ( return (
<div className="space-y-4 animate-pulse" aria-label="Loading audiobook settings" aria-busy="true"> <div className="space-y-4 animate-pulse" aria-label="Loading audiobook settings" aria-busy="true">
<div className="w-full rounded-xl border border-offbase bg-background overflow-hidden"> <div className="w-full rounded-lg border border-line bg-background overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-offbase bg-base"> <div className="flex items-center justify-between px-4 py-3 border-b border-line-soft bg-surface">
<div className="h-4 w-40 rounded bg-offbase" /> <div className="h-4 w-40 rounded bg-surface-sunken" />
<div className="h-5 w-14 rounded bg-offbase" /> <div className="h-5 w-14 rounded bg-surface-sunken" />
</div> </div>
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="h-3 w-16 rounded bg-offbase" /> <div className="h-3 w-16 rounded bg-surface-sunken" />
<div className="h-9 w-full rounded-md bg-offbase" /> <div className="h-9 w-full rounded-md bg-surface-sunken" />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="h-3 w-16 rounded bg-offbase" /> <div className="h-3 w-16 rounded bg-surface-sunken" />
<div className="h-9 w-full rounded-md bg-offbase" /> <div className="h-9 w-full rounded-md bg-surface-sunken" />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="h-3 w-24 rounded bg-offbase" /> <div className="h-3 w-24 rounded bg-surface-sunken" />
<div className="h-2 w-full rounded bg-offbase" /> <div className="h-2 w-full rounded bg-surface-sunken" />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="h-3 w-20 rounded bg-offbase" /> <div className="h-3 w-20 rounded bg-surface-sunken" />
<div className="h-2 w-full rounded bg-offbase" /> <div className="h-2 w-full rounded bg-surface-sunken" />
</div> </div>
</div> </div>
<div className="h-9 w-full rounded-md bg-offbase" /> <div className="h-9 w-full rounded-md bg-surface-sunken" />
</div> </div>
</div> </div>
<div className="w-full rounded-xl border border-offbase bg-background overflow-hidden"> <div className="w-full rounded-lg border border-line bg-background overflow-hidden">
<div className="px-4 py-3 border-b border-offbase bg-base"> <div className="px-4 py-3 border-b border-line-soft bg-surface">
<div className="h-4 w-28 rounded bg-offbase" /> <div className="h-4 w-28 rounded bg-surface-sunken" />
</div> </div>
<div className="p-4 space-y-2"> <div className="p-4 space-y-2">
{Array.from({ length: 4 }).map((_, index) => ( {Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-16 rounded-lg border border-offbase bg-base" /> <div key={index} className="h-16 rounded-lg border border-line bg-surface" />
))} ))}
</div> </div>
</div> </div>

View file

@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { CopyIcon, CheckIcon } from '@/components/icons/Icons'; import { CopyIcon, CheckIcon } from '@/components/icons/Icons';
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { Button } from '@/components/ui';
export function CodeBlock({ children }: { children: string }) { export function CodeBlock({ children }: { children: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -16,20 +16,18 @@ export function CodeBlock({ children }: { children: string }) {
return ( return (
<div className="relative group my-2"> <div className="relative group my-2">
<pre className="bg-background p-3 rounded-md overflow-x-auto text-xs text-left <pre className="bg-background p-3 rounded-md overflow-x-auto text-xs text-left
font-mono text-foreground border border-offbase"> font-mono text-foreground border border-line">
<code>{children}</code> <code>{children}</code>
</pre> </pre>
<button <Button
onClick={handleCopy} onClick={handleCopy}
className={buttonClass({ variant="secondary"
variant: 'secondary', size="icon"
size: 'icon', className="absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100"
className: 'absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100 hover:scale-[1.05]',
})}
title="Copy to clipboard" title="Copy to clipboard"
> >
{copied ? <CheckIcon className="w-4 h-4" /> : <CopyIcon className="w-4 h-4" />} {copied ? <CheckIcon className="w-4 h-4" /> : <CopyIcon className="w-4 h-4" />}
</button> </Button>
</div> </div>
); );
} }

View file

@ -1,9 +1,10 @@
'use client'; 'use client';
import { useRef, useState, useEffect, useCallback } from 'react'; 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 { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext';
import { PaletteIcon } from '@/components/icons/Icons'; import { PaletteIcon } from '@/components/icons/Icons';
import { IconButton, Input, PopoverSurface } from '@/components/ui';
/** /**
* Curated swatch palettes per color role, sourced from existing themes * Curated swatch palettes per color role, sourced from existing themes
@ -78,9 +79,9 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
return ( return (
<Popover className="relative flex items-center"> <Popover className="relative flex items-center">
<PopoverButton className="cursor-pointer group focus:outline-none"> <PopoverButton as={IconButton} size="sm" className="group rounded-full p-0" aria-label={`Pick ${label} color`}>
<div <div
className="w-6 h-6 rounded-full border-2 transition-all duration-150 group-hover:scale-110 group-focus-visible:ring-2 group-focus-visible:ring-offset-1" className="w-6 h-6 rounded-full border-2 transition duration-fast group-focus-visible:ring-2 group-focus-visible:ring-offset-1"
style={{ style={{
backgroundColor: value, backgroundColor: value,
borderColor: isLightColor(value) ? '#00000022' : '#ffffff22', borderColor: isLightColor(value) ? '#00000022' : '#ffffff22',
@ -88,11 +89,10 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
/> />
</PopoverButton> </PopoverButton>
<PopoverPanel <PopoverSurface
anchor="bottom start" anchor="bottom start"
transition transition
className="z-[60] mt-2 w-56 rounded-xl shadow-xl border border-offbase bg-background p-3 space-y-3 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"
transition duration-150 ease-out data-[closed]:opacity-0 data-[closed]:scale-95"
> >
{/* Label */} {/* Label */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -101,14 +101,14 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
</span> </span>
{/* Eyedropper / native picker */} {/* Eyedropper / native picker */}
<div className="relative"> <div className="relative">
<button <IconButton
type="button" type="button"
onClick={() => nativeRef.current?.click()} onClick={() => nativeRef.current?.click()}
className="p-1" size="xs"
aria-label="Open system color picker" aria-label="Open system color picker"
> >
<PaletteIcon className="w-4 h-4 text-muted transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" /> <PaletteIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
</button> </IconButton>
<input <input
ref={nativeRef} ref={nativeRef}
type="color" type="color"
@ -130,7 +130,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
key={color} key={color}
type="button" type="button"
onClick={() => onChange(color)} onClick={() => onChange(color)}
className="group/swatch relative w-full aspect-square rounded-full transition-transform duration-100 hover:scale-125 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1" className="group/swatch relative w-full aspect-square rounded-full transition-transform duration-fast focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1"
style={{ style={{
backgroundColor: color, backgroundColor: color,
boxShadow: selected ? '0 0 0 2px var(--background), 0 0 0 4px var(--foreground)' : undefined, boxShadow: selected ? '0 0 0 2px var(--background), 0 0 0 4px var(--foreground)' : undefined,
@ -156,7 +156,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
borderColor: isLightColor(value) ? '#00000018' : '#ffffff18', borderColor: isLightColor(value) ? '#00000018' : '#ffffff18',
}} }}
/> />
<input <Input
type="text" type="text"
value={hexInput} value={hexInput}
onChange={(e) => setHexInput(e.target.value)} onChange={(e) => setHexInput(e.target.value)}
@ -166,10 +166,11 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
}} }}
spellCheck={false} spellCheck={false}
maxLength={7} maxLength={7}
className="flex-1 rounded-lg px-2 py-1 text-xs font-mono border border-offbase bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-accent" controlSize="sm"
className="flex-1 font-mono"
/> />
</div> </div>
</PopoverPanel> </PopoverSurface>
</Popover> </Popover>
); );
} }

View file

@ -1,6 +1,5 @@
import { Fragment, KeyboardEvent } from 'react'; import { KeyboardEvent } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; import { Button, ModalFrame, ModalTitle } from '@/components/ui';
import { buttonClass } from '@/components/ui/buttonPrimitives';
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean; isOpen: boolean;
@ -31,77 +30,25 @@ export function ConfirmDialog({
}; };
return ( return (
<Transition appear show={isOpen} as={Fragment}> <ModalFrame open={isOpen} onClose={onClose} onKeyDown={handleKeyDown} panelTestId="confirm-dialog-panel">
<Dialog <ModalTitle>{title}</ModalTitle>
as="div" <div className="mt-2">
role={undefined} <p className="text-sm text-soft break-words">{message}</p>
className="relative z-50" </div>
onClose={onClose}
onKeyDown={handleKeyDown} <div className="mt-6 flex justify-end space-x-3">
> <Button variant="outline" size="sm" onClick={onClose}>
<TransitionChild {cancelText}
as={Fragment} </Button>
enter="ease-out duration-300" <Button
enterFrom="opacity-0" variant={isDangerous ? 'danger' : 'primary'}
enterTo="opacity-100" size="sm"
leave="ease-in duration-200" className="text-wrap"
leaveFrom="opacity-100" onClick={onConfirm}
leaveTo="opacity-0"
> >
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" /> {confirmText}
</TransitionChild> </Button>
</div>
<div className="fixed inset-0 overflow-y-auto"> </ModalFrame>
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel role='dialog' className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground"
>
{title}
</DialogTitle>
<div className="mt-2">
<p className="text-sm text-foreground/90 break-words">{message}</p>
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
type="button"
className={buttonClass({
variant: 'outline',
size: 'sm',
className: 'hover:scale-[1.04]',
})}
onClick={onClose}
>
{cancelText}
</button>
<button
type="button"
className={buttonClass({
variant: isDangerous ? 'danger' : 'primary',
size: 'sm',
className: 'text-wrap hover:scale-[1.04]',
})}
onClick={onConfirm}
>
{confirmText}
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
); );
} }

View file

@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { getConsentState, setConsentState } from '@/lib/client/analytics'; import { getConsentState, setConsentState } from '@/lib/client/analytics';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { Button } from '@/components/ui';
export function CookieConsentBanner() { export function CookieConsentBanner() {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
@ -35,46 +35,42 @@ export function CookieConsentBanner() {
<Transition <Transition
as="div" as="div"
show={show} show={show}
enter="transition ease-out duration-300 transform" enter="transition ease-standard duration-slow transform"
enterFrom="translate-y-full opacity-0" enterFrom="translate-y-full opacity-0"
enterTo="translate-y-0 opacity-100" enterTo="translate-y-0 opacity-100"
leave="transition ease-in duration-200 transform" leave="transition ease-standard duration-base transform"
leaveFrom="translate-y-0 opacity-100" leaveFrom="translate-y-0 opacity-100"
leaveTo="translate-y-full opacity-0" leaveTo="translate-y-full opacity-0"
className="fixed bottom-0 left-0 right-0 z-[60] px-4 pt-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6 md:pt-6 md:pb-[max(1.5rem,env(safe-area-inset-bottom))]" className="fixed bottom-0 left-0 right-0 z-[60] px-4 pt-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6 md:pt-6 md:pb-[max(1.5rem,env(safe-area-inset-bottom))]"
> >
<div className="mx-auto max-w-5xl rounded-xl border border-offbase bg-base p-5 shadow-2xl md:flex md:items-center md:justify-between md:gap-8"> <div className="mx-auto max-w-5xl rounded-lg border border-line bg-surface p-5 shadow-elev-3 md:flex md:items-center md:justify-between md:gap-8">
<div className="mb-4 md:mb-0"> <div className="mb-4 md:mb-0">
<h3 className="mb-2 text-lg font-bold"> <h3 className="mb-2 text-lg font-bold">
🍪 We use cookies 🍪 We use cookies
</h3> </h3>
<p className="text-sm leading-relaxed text-foreground/90"> <p className="text-sm leading-relaxed text-soft">
We use strictly necessary cookies for authentication. Optional analytics is enabled only when you consent. We use strictly necessary cookies for authentication. Optional analytics is enabled only when you consent.
See our <Link href="/privacy" className="font-medium text-accent hover:underline">Privacy Policy</Link> for details. See our <Link href="/privacy" className="font-medium text-accent hover:underline">Privacy Policy</Link> for details.
</p> </p>
</div> </div>
<div className="flex flex-col gap-3 min-w-fit sm:flex-row"> <div className="flex flex-col gap-3 min-w-fit sm:flex-row">
<button <Button
onClick={handleDecline} onClick={handleDecline}
className={buttonClass({ variant="ghost"
variant: 'ghost', size="md"
size: 'md', className="whitespace-nowrap"
className: 'whitespace-nowrap',
})}
> >
Decline Non-Essential Decline Non-Essential
</button> </Button>
<button <Button
onClick={handleAccept} onClick={handleAccept}
className={buttonClass({ variant="primary"
variant: 'primary', size="md"
size: 'md', className="whitespace-nowrap font-bold shadow-elev-1"
className: 'whitespace-nowrap font-bold shadow-sm',
})}
> >
Accept All Accept All
</button> </Button>
</div> </div>
</div> </div>
</Transition> </Transition>

View file

@ -1,3 +1,4 @@
import { AppHeader } from '@/components/layout';
import { ReactNode } from "react"; import { ReactNode } from "react";
export function Header({ export function Header({
@ -9,19 +10,5 @@ export function Header({
title?: ReactNode; title?: ReactNode;
right?: ReactNode; right?: ReactNode;
}) { }) {
return ( return <AppHeader left={left} title={title} right={right} />;
<div className="sticky top-0 z-40 w-full border-b border-offbase bg-base" data-app-header>
<div className="px-2 sm:px-3 py-1 flex items-center justify-between gap-2 min-h-10">
<div className="flex items-center gap-2 min-w-0 flex-1">
{left}
{typeof title === 'string' ? (
<h1 className="text-xs md:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1>
) : (
title
)}
</div>
<div className="flex items-center gap-2 min-w-0 justify-end">{right}</div>
</div>
</div>
);
} }

View file

@ -21,8 +21,8 @@ export function HomeContent() {
const appActions = ( const appActions = (
<div className="flex flex-col gap-0.5 w-full"> <div className="flex flex-col gap-0.5 w-full">
<SettingsTrigger <SettingsTrigger
variant="sidebar"
triggerLabel="Settings" triggerLabel="Settings"
className="w-full justify-start gap-2 px-2 py-1 text-[12px] border-transparent hover:border-accent"
onOpen={() => setSettingsOpen(true)} onOpen={() => setSettingsOpen(true)}
/> />
<UserMenu variant="sidebar" /> <UserMenu variant="sidebar" />

View file

@ -1,15 +1,8 @@
'use client'; 'use client';
import { Fragment, useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import {
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
Button,
} from '@headlessui/react';
import { updateAppConfig } from '@/lib/client/dexie'; import { updateAppConfig } from '@/lib/client/dexie';
import { Button, ModalFrame, ModalTitle } from '@/components/ui';
interface PrivacyModalProps { interface PrivacyModalProps {
isOpen: boolean; isOpen: boolean;
@ -19,9 +12,9 @@ interface PrivacyModalProps {
function PrivacyModalBody({ origin }: { origin: string }) { function PrivacyModalBody({ origin }: { origin: string }) {
return ( return (
<div className="mt-4 space-y-4 text-sm text-foreground/90"> <div className="mt-4 space-y-4 text-sm text-soft">
<div className="rounded-lg border border-offbase bg-offbase/40 p-3"> <div className="rounded-lg border border-line bg-surface-sunken p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Service Operator</div> <div className="text-xs font-semibold uppercase tracking-wide text-soft">Service Operator</div>
<div className="mt-1"> <div className="mt-1">
This instance is hosted at <span className="font-bold">{origin || 'this server'}</span>. This instance is hosted at <span className="font-bold">{origin || 'this server'}</span>.
</div> </div>
@ -77,85 +70,46 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
}; };
return ( return (
<Transition appear show={isOpen} as={Fragment}> <ModalFrame open={isOpen} onClose={onDismiss ?? (() => {})} panelTestId="privacy-modal" className="z-[80]">
<Dialog as="div" className="relative z-[80]" onClose={onDismiss ?? (() => { })}> <ModalTitle>Privacy & Data Usage</ModalTitle>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto"> <PrivacyModalBody origin={origin} />
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel data-testid="privacy-modal" className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground"
>
Privacy & Data Usage
</DialogTitle>
<PrivacyModalBody origin={origin} /> <div className="mt-6 space-y-4">
<div className="flex items-start gap-3 rounded-lg border border-line p-3 bg-surface-sunken">
<div className="mt-6 space-y-4"> <div className="flex h-6 items-center">
<div className="flex items-start gap-3 rounded-lg border border-offbase p-3 bg-offbase/20"> <input
<div className="flex h-6 items-center"> data-testid="privacy-agree-checkbox"
<input id="privacy-agree"
data-testid="privacy-agree-checkbox" type="checkbox"
id="privacy-agree" checked={agreed}
type="checkbox" onChange={(e) => setAgreed(e.target.checked)}
checked={agreed} className="h-4 w-4 rounded border-line text-accent focus:ring-accent-line bg-surface"
onChange={(e) => setAgreed(e.target.checked)} />
className="h-4 w-4 rounded border-gray-300 text-accent focus:ring-accent bg-base" </div>
/> <div className="text-sm leading-6">
</div> <label htmlFor="privacy-agree" className="font-medium text-foreground select-none cursor-pointer">
<div className="text-sm leading-6"> I have read and agree to the
<label htmlFor="privacy-agree" className="font-medium text-foreground select-none cursor-pointer"> </label>{' '}
I have read and agree to the <a href="/privacy" target="_blank" className="font-semibold text-accent hover:underline">
</label>{' '} Privacy Policy
<a href="/privacy" target="_blank" className="font-semibold text-accent hover:underline"> </a>
Privacy Policy
</a>
</div>
</div>
<div className="flex justify-end">
<Button
data-testid="privacy-continue-button"
type="button"
disabled={!agreed}
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
font-medium text-background hover:bg-secondary-accent
disabled:opacity-50 disabled:cursor-not-allowed
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out enabled:hover:scale-[1.04]"
onClick={handleAccept}
>
Continue
</Button>
</div>
</div>
</DialogPanel>
</TransitionChild>
</div> </div>
</div> </div>
</Dialog>
</Transition> <div className="flex justify-end">
<Button
data-testid="privacy-continue-button"
variant="primary"
size="lg"
disabled={!agreed}
onClick={handleAccept}
>
Continue
</Button>
</div>
</div>
</ModalFrame>
); );
} }
@ -183,67 +137,28 @@ export function showPrivacyModal(): void {
}; };
return ( return (
<Transition <ModalFrame
appear open={show}
show={show} onClose={handleClose}
as={Fragment}
afterLeave={() => { afterLeave={() => {
root.unmount(); root.unmount();
container.remove(); container.remove();
}} }}
> >
<Dialog as="div" className="relative z-50" onClose={handleClose}> <ModalTitle>Privacy & Data Usage</ModalTitle>
<TransitionChild
as={Fragment} <PrivacyModalBody origin={origin} />
enter="ease-out duration-300"
enterFrom="opacity-0" <div className="mt-6 flex justify-end">
enterTo="opacity-100" <Button
leave="ease-in duration-200" variant="primary"
leaveFrom="opacity-100" size="lg"
leaveTo="opacity-0" onClick={handleClose}
> >
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" /> Close
</TransitionChild> </Button>
</div>
<div className="fixed inset-0 overflow-y-auto"> </ModalFrame>
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground"
>
Privacy & Data Usage
</DialogTitle>
<PrivacyModalBody origin={origin} />
<div className="mt-6 flex justify-end">
<Button
type="button"
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
onClick={handleClose}
>
Close
</Button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
); );
}; };

View file

@ -1,4 +1,4 @@
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { Button } from '@/components/ui';
interface ProgressCardProps { interface ProgressCardProps {
progress: number; progress: number;
@ -32,7 +32,7 @@ export function ProgressCard({
const operationLabel = getOperationLabel(); const operationLabel = getOperationLabel();
return ( return (
<div className="bg-offbase rounded-lg p-3 space-y-2"> <div className="bg-surface-sunken rounded-lg p-3 space-y-2">
{/* Header with operation type and cancel button */} {/* Header with operation type and cancel button */}
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0 space-y-1"> <div className="flex-1 min-w-0 space-y-1">
@ -52,29 +52,27 @@ export function ProgressCard({
</div> </div>
)} )}
</div> </div>
<button <Button
type="button" type="button"
className={buttonClass({ variant="ghost"
variant: 'ghost', size="xs"
size: 'xs', className="shrink-0"
className: 'shrink-0 hover:bg-background/50 hover:scale-[1.04]',
})}
onClick={(e) => onCancel(e)} onClick={(e) => onCancel(e)}
> >
<span>{cancelText}</span> <span>{cancelText}</span>
</button> </Button>
</div> </div>
{/* Progress bar */} {/* Progress bar */}
<div className="w-full bg-background rounded-full overflow-hidden h-1.5"> <div className="w-full bg-background rounded-full overflow-hidden h-1.5">
<div <div
className="h-full bg-accent transition-all duration-300 ease-out" className="h-full bg-accent transition duration-slow ease-standard"
style={{ width: `${progress}%` }} style={{ width: `${progress}%` }}
/> />
</div> </div>
{/* Stats row */} {/* Stats row */}
<div className="flex items-center gap-2 text-xs text-muted"> <div className="flex items-center gap-2 text-xs text-soft">
{completedChapters !== undefined && ( {completedChapters !== undefined && (
<> <>
<span className="font-medium">{completedChapters} chapters</span> <span className="font-medium">{completedChapters} chapters</span>

View file

@ -32,17 +32,17 @@ export function ProgressPopup({
<Transition <Transition
show={isOpen} show={isOpen}
as={Fragment} as={Fragment}
enter="transform transition ease-out duration-300" enter="transform transition ease-standard duration-slow"
enterFrom="opacity-0 -translate-y-4" enterFrom="opacity-0 -translate-y-4"
enterTo="opacity-100 translate-y-0" enterTo="opacity-100 translate-y-0"
leave="transform transition ease-in duration-200" leave="transform transition ease-standard duration-base"
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-4" leaveTo="opacity-0 -translate-y-4"
> >
<div className="fixed inset-x-0 top-2 z-[60] pointer-events-none px-4"> <div className="fixed inset-x-0 top-2 z-[60] pointer-events-none px-4">
<div className="w-full max-w-md mx-auto"> <div className="w-full max-w-md mx-auto">
<div <div
className={`pointer-events-auto shadow-xl ${ className={`pointer-events-auto shadow-elev-3 ${
onClick ? 'cursor-pointer' : '' onClick ? 'cursor-pointer' : ''
}`} }`}
onClick={onClick} onClick={onClick}

View file

@ -2,17 +2,8 @@
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; import { Fragment, useState, useEffect, useCallback, useMemo } from 'react';
import { import {
Dialog,
DialogPanel,
DialogTitle,
Transition, Transition,
TransitionChild,
Listbox, Listbox,
ListboxButton,
ListboxOptions,
ListboxOption,
Button,
Input,
} from '@headlessui/react'; } from '@headlessui/react';
import Link from 'next/link'; import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
@ -52,14 +43,20 @@ import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
import { useSharedProviders } from '@/hooks/useSharedProviders'; import { useSharedProviders } from '@/hooks/useSharedProviders';
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery'; import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
import { import {
SidebarNav,
SidebarNavItem,
SegmentedControl,
Button,
ChoiceTile,
IconButton,
Input,
ModalFrame,
ModalTitle,
inputClass, inputClass,
listboxButtonClass, SharedListboxButton,
listboxOptionClass, SharedListboxOption,
listboxOptionsClass, SharedListboxOptions,
segmentedButtonClass, } from '@/components/ui';
segmentedGroupClass,
} from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { fetchChangelogManifest, fetchChangelogReleaseBody } from '@/lib/client/changelog'; import { fetchChangelogManifest, fetchChangelogReleaseBody } from '@/lib/client/changelog';
@ -110,6 +107,54 @@ const CUSTOM_COLOR_FIELDS: { key: keyof CustomThemeColors; label: string }[] = [
{ key: 'muted', label: 'Muted' }, { key: 'muted', label: 'Muted' },
]; ];
function ThemeSwatches({ colors }: { colors: ThemeColorSet }) {
return (
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
);
}
function ThemeChoice({
label,
colors,
selected,
onClick,
className,
}: {
label: string;
colors: ThemeColorSet;
selected: boolean;
onClick: () => void;
className?: string;
}) {
return (
<ChoiceTile
selected={selected}
onClick={onClick}
className={className}
style={{ backgroundColor: colors.base }}
>
{selected ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0" style={{ color: colors.accent }} />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
{label}
</span>
<ThemeSwatches colors={colors} />
</ChoiceTile>
);
}
type SectionId = 'api' | 'theme' | 'docs' | 'account' | 'admin'; type SectionId = 'api' | 'theme' | 'docs' | 'account' | 'admin';
type SidebarSection = { type SidebarSection = {
@ -133,20 +178,37 @@ type AdminSubTab = 'providers' | 'features';
export function SettingsTrigger({ export function SettingsTrigger({
className = '', className = '',
triggerLabel, triggerLabel,
variant = 'button',
onOpen, onOpen,
}: { }: {
className?: string; className?: string;
triggerLabel?: string; triggerLabel?: string;
variant?: 'button' | 'sidebar';
onOpen: () => void; onOpen: () => void;
}) { }) {
if (variant === 'sidebar') {
return (
<SidebarNavItem
compact
onClick={onOpen}
className={className}
aria-label="Settings"
icon={<SettingsIcon className="w-3.5 h-3.5" />}
label={triggerLabel ?? 'Settings'}
/>
);
}
return ( return (
<Button <Button
variant="secondary"
size="sm"
onClick={onOpen} onClick={onOpen}
className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase hover:text-accent transition-transform transition-colors duration-200 ease-out hover:scale-[1.01] ${className}`} className={className}
aria-label="Settings" aria-label="Settings"
tabIndex={0} tabIndex={0}
> >
<SettingsIcon className="w-4 h-4 transition-transform duration-200 ease-out hover:scale-[1.01] hover:rotate-45" /> <SettingsIcon className="w-4 h-4 transition-transform duration-base ease-standard hover:rotate-45" />
{triggerLabel && <span className="ml-2">{triggerLabel}</span>} {triggerLabel && <span className="ml-2">{triggerLabel}</span>}
</Button> </Button>
); );
@ -483,8 +545,8 @@ export function SettingsModal({
setActiveSection(visibleSections[0]?.id ?? 'theme'); setActiveSection(visibleSections[0]?.id ?? 'theme');
}, [activeSection, visibleSections]); }, [activeSection, visibleSections]);
const fieldLabelClass = 'block text-[11px] font-semibold uppercase tracking-wide text-muted'; const fieldLabelClass = 'block text-[11px] font-semibold uppercase tracking-wide text-soft';
const sectionShellClass = 'space-y-2 pb-3 border-b border-offbase px-0.5'; const sectionShellClass = 'space-y-2 pb-3 border-b border-line-soft px-0.5';
const sectionHeadingClass = 'text-sm font-semibold text-foreground'; const sectionHeadingClass = 'text-sm font-semibold text-foreground';
const effectiveProviderType = resolveEffectiveProviderType({ const effectiveProviderType = resolveEffectiveProviderType({
providerRef: selectedProviderRef, providerRef: selectedProviderRef,
@ -511,53 +573,32 @@ export function SettingsModal({
return ( return (
<> <>
<Transition appear show={isOpen} as={Fragment}> <ModalFrame
<Dialog open={isOpen}
as="div" onClose={resetToCurrent}
className={`relative ${isChangelogOpen ? 'z-[90]' : 'z-50'}`} size="xl"
onClose={resetToCurrent} panelTestId="settings-modal"
> className={isChangelogOpen ? 'z-[90]' : 'z-50'}
<TransitionChild >
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel data-testid="settings-modal" className="relative w-full max-w-4xl transform rounded-xl bg-base text-left align-middle shadow-xl transition-all overflow-hidden border border-offbase">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-offbase"> <div className="flex items-center justify-between px-5 py-3 border-b border-line-soft">
<div className="flex items-baseline gap-4"> <div className="flex items-baseline gap-4">
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground"> <ModalTitle>Settings</ModalTitle>
Settings
</DialogTitle>
<Button <Button
variant="ghost"
size="sm"
onClick={() => setIsChangelogOpen(true)} onClick={() => setIsChangelogOpen(true)}
className="text-sm font-medium leading-6 text-muted hover:text-accent transition-colors" className="text-sm font-medium leading-6 text-soft hover:text-accent transition-colors"
> >
{displayVersion ? `v${displayVersion} · Changelog` : 'Changelog'} {displayVersion ? `v${displayVersion} · Changelog` : 'Changelog'}
</Button> </Button>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<Button <Button
variant="ghost"
size="sm"
onClick={() => showPrivacyModal()} onClick={() => showPrivacyModal()}
className="text-sm font-medium text-muted hover:text-accent transition-colors" className="text-sm font-medium text-soft hover:text-accent transition-colors"
> >
Privacy Privacy
</Button> </Button>
@ -572,50 +613,42 @@ export function SettingsModal({
/> />
) : ( ) : (
<> <>
{/* Mobile: 2x2 grid nav */} {/* Mobile nav */}
<div className="grid grid-cols-2 gap-1 sm:hidden border-b border-offbase bg-background p-2"> <SidebarNav layout="grid" className="sm:hidden border-b border-line-soft bg-background p-2">
{visibleSections.map((section) => { {visibleSections.map((section) => {
const Icon = section.icon; const Icon = section.icon;
return ( return (
<button <SidebarNavItem
compact
key={section.id} key={section.id}
onClick={() => setActiveSection(section.id)} onClick={() => setActiveSection(section.id)}
className={`flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors ${ active={activeSection === section.id}
activeSection === section.id icon={<Icon className="w-3.5 h-3.5" />}
? 'bg-accent text-background' label={section.label}
: 'text-foreground hover:bg-offbase hover:text-accent' />
}`}
>
<Icon className="w-3.5 h-3.5" />
{section.label}
</button>
); );
})} })}
</div> </SidebarNav>
<div className="flex flex-row h-[490px]"> <div className="flex flex-row h-[490px]">
{/* Desktop: vertical sidebar */} {/* Desktop: vertical sidebar */}
<nav className="hidden sm:block w-44 shrink-0 border-r border-offbase bg-background p-2"> <nav className="hidden sm:block w-44 shrink-0 border-r border-line-soft bg-background p-2">
<div className="flex flex-col gap-1"> <SidebarNav>
{visibleSections.map((section) => { {visibleSections.map((section) => {
const Icon = section.icon; const Icon = section.icon;
const active = activeSection === section.id; const active = activeSection === section.id;
return ( return (
<button <SidebarNavItem
key={section.id} key={section.id}
onClick={() => setActiveSection(section.id)} onClick={() => setActiveSection(section.id)}
className={`w-full flex items-center gap-2.5 text-left px-2.5 py-1.5 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${ active={active}
active icon={<Icon className="w-4 h-4" />}
? 'bg-accent text-background' label={section.label}
: 'text-foreground hover:bg-base hover:text-accent' className="whitespace-nowrap"
}`} />
>
<Icon className="w-4 h-4 shrink-0" />
{section.label}
</button>
); );
})} })}
</div> </SidebarNav>
</nav> </nav>
{/* Content */} {/* Content */}
@ -630,7 +663,7 @@ export function SettingsModal({
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className={fieldLabelClass}>TTS Provider</label> <label className={fieldLabelClass}>TTS Provider</label>
{ttsProviders.length === 0 ? ( {ttsProviders.length === 0 ? (
<p className="text-xs text-amber-500"> <p className="text-xs text-accent">
User API keys are restricted and no shared provider is configured. Ask an admin to add one. User API keys are restricted and no shared provider is configured. Ask an admin to add one.
</p> </p>
) : ( ) : (
@ -656,28 +689,26 @@ export function SettingsModal({
setCustomModelInput(''); setCustomModelInput('');
}} }}
> >
<ListboxButton className={listboxButtonClass}> <SharedListboxButton>
<span className="block truncate"> <span className="block truncate">
{selectedProviderOption?.name || 'Select Provider'} {selectedProviderOption?.name || 'Select Provider'}
</span> </span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" /> <ChevronUpDownIcon className="h-5 w-5 text-soft" />
</span> </span>
</ListboxButton> </SharedListboxButton>
<Transition <Transition
as={Fragment} as={Fragment}
leave="transition ease-in duration-100" leave="transition ease-standard duration-fast"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions <SharedListboxOptions
anchor="bottom start" anchor="bottom start"
className={listboxOptionsClass}
> >
{ttsProviders.map((provider) => ( {ttsProviders.map((provider) => (
<ListboxOption <SharedListboxOption
key={provider.id} key={provider.id}
className={({ active }) => listboxOptionClass(active)}
value={provider} value={provider}
> >
{({ selected }) => ( {({ selected }) => (
@ -692,15 +723,15 @@ export function SettingsModal({
)} )}
</> </>
)} )}
</ListboxOption> </SharedListboxOption>
))} ))}
</ListboxOptions> </SharedListboxOptions>
</Transition> </Transition>
</Listbox> </Listbox>
)} )}
</div> </div>
{restrictUserApiKeys && ( {restrictUserApiKeys && (
<p className="text-xs text-muted"> <p className="text-xs text-soft">
This instance restricts user API keys. TTS runs through admin-configured shared providers only. This instance restricts user API keys. TTS runs through admin-configured shared providers only.
</p> </p>
)} )}
@ -737,7 +768,7 @@ export function SettingsModal({
</div> </div>
)} )}
{isSharedSelected && ( {isSharedSelected && (
<p className="text-xs text-muted"> <p className="text-xs text-soft">
This is a shared provider configured by an admin. API key and base URL are managed server-side. This is a shared provider configured by an admin. API key and base URL are managed server-side.
</p> </p>
)} )}
@ -745,7 +776,7 @@ export function SettingsModal({
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className={fieldLabelClass}>TTS Model</label> <label className={fieldLabelClass}>TTS Model</label>
{!showAllProviderModels && ( {!showAllProviderModels && (
<p className="text-xs text-muted"> <p className="text-xs text-soft">
This instance restricts model selection to each provider&apos;s default model. This instance restricts model selection to each provider&apos;s default model.
</p> </p>
)} )}
@ -761,14 +792,14 @@ export function SettingsModal({
} }
}} }}
> >
<ListboxButton className={listboxButtonClass}> <SharedListboxButton>
{selectedModel ? ( {selectedModel ? (
<span className="block"> <span className="block">
<span className="block truncate"> <span className="block truncate">
{selectedModel.name} {selectedModel.name}
</span> </span>
{selectedModelVersion && ( {selectedModelVersion && (
<span className="block truncate text-xs text-muted"> <span className="block truncate text-xs text-soft">
{selectedModelVersion} {selectedModelVersion}
</span> </span>
)} )}
@ -777,23 +808,21 @@ export function SettingsModal({
<span className="block truncate">Select Model</span> <span className="block truncate">Select Model</span>
)} )}
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" /> <ChevronUpDownIcon className="h-5 w-5 text-soft" />
</span> </span>
</ListboxButton> </SharedListboxButton>
<Transition <Transition
as={Fragment} as={Fragment}
leave="transition ease-in duration-100" leave="transition ease-standard duration-fast"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions <SharedListboxOptions
anchor="bottom start" anchor="bottom start"
className={listboxOptionsClass}
> >
{ttsModels.map((model) => ( {ttsModels.map((model) => (
<ListboxOption <SharedListboxOption
key={model.id} key={model.id}
className={({ active }) => listboxOptionClass(active)}
value={model} value={model}
> >
{({ selected }) => ( {({ selected }) => (
@ -801,7 +830,7 @@ export function SettingsModal({
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}> <span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
<span className="block truncate">{model.name}</span> <span className="block truncate">{model.name}</span>
{model.id.includes(':') && ( {model.id.includes(':') && (
<span className="block truncate text-xs text-muted"> <span className="block truncate text-xs text-soft">
{model.id.slice(model.id.indexOf(':'))} {model.id.slice(model.id.indexOf(':'))}
</span> </span>
)} )}
@ -813,9 +842,9 @@ export function SettingsModal({
)} )}
</> </>
)} )}
</ListboxOption> </SharedListboxOption>
))} ))}
</ListboxOptions> </SharedListboxOptions>
</Transition> </Transition>
</Listbox> </Listbox>
@ -849,7 +878,8 @@ export function SettingsModal({
<div className="pt-4 flex justify-end gap-2"> <div className="pt-4 flex justify-end gap-2">
<Button <Button
type="button" type="button"
className={buttonClass({ variant: 'secondary', size: 'md' })} variant="secondary"
size="md"
onClick={async () => { onClick={async () => {
const defaults = resolveProviderDefaults({ const defaults = resolveProviderDefaults({
providerRef: runtimeConfig.defaultTtsProvider, providerRef: runtimeConfig.defaultTtsProvider,
@ -869,7 +899,8 @@ export function SettingsModal({
<Button <Button
data-testid="settings-save-button" data-testid="settings-save-button"
type="button" type="button"
className={buttonClass({ variant: 'primary', size: 'md' })} variant="primary"
size="md"
disabled={!canSubmit} disabled={!canSubmit}
onClick={async () => { onClick={async () => {
const defaults = resolveProviderDefaults({ const defaults = resolveProviderDefaults({
@ -906,88 +937,48 @@ export function SettingsModal({
const colors = getThemeColors(systemTheme.id); const colors = getThemeColors(systemTheme.id);
const isActive = theme === systemTheme.id; const isActive = theme === systemTheme.id;
return ( return (
<button <ThemeChoice
label={systemTheme.name}
colors={colors}
selected={isActive}
onClick={() => setTheme(systemTheme.id)} onClick={() => setTheme(systemTheme.id)}
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 w-full text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border className="w-full"
${isActive />
? 'border-accent'
: 'border-offbase hover:border-muted'
}`}
style={{ backgroundColor: colors.base }}
>
{isActive ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-accent" />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
{systemTheme.name}
</span>
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
</button>
); );
})()} })()}
</div> </div>
{/* Custom theme */} {/* Custom theme */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-xs font-medium text-muted uppercase tracking-wide">Custom</label> <label className="block text-xs font-medium text-soft uppercase tracking-wide">Custom</label>
{(() => { {(() => {
const colors = getThemeColors('custom'); const colors = getThemeColors('custom');
const isActive = theme === 'custom'; const isActive = theme === 'custom';
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <ThemeChoice
label="Custom"
colors={colors}
selected={isActive}
onClick={() => { onClick={() => {
setTheme('custom'); setTheme('custom');
setIsCustomExpanded(true); setIsCustomExpanded(true);
}} }}
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 flex-1 text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border className="flex-1"
${isActive />
? 'border-accent' <IconButton
: 'border-offbase hover:border-muted'
}`}
style={{ backgroundColor: colors.base }}
>
{isActive ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0" style={{ color: colors.accent }} />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
Custom
</span>
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
</button>
<button
onClick={() => setIsCustomExpanded(!isCustomExpanded)} onClick={() => setIsCustomExpanded(!isCustomExpanded)}
className="shrink-0 p-1.5 rounded-lg border border-offbase hover:border-muted transition-colors" tone="surface"
size="sm"
className="shrink-0"
style={{ color: colors.muted, backgroundColor: colors.base }} style={{ color: colors.muted, backgroundColor: colors.base }}
aria-label={isCustomExpanded ? 'Collapse color picker' : 'Expand color picker'} aria-label={isCustomExpanded ? 'Collapse color picker' : 'Expand color picker'}
> >
<svg className={`w-3.5 h-3.5 transition-transform duration-200 ${isCustomExpanded ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className={`w-3.5 h-3.5 transition-transform duration-base ${isCustomExpanded ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg> </svg>
</button> </IconButton>
</div> </div>
{isCustomExpanded && ( {isCustomExpanded && (
@ -1043,41 +1034,19 @@ export function SettingsModal({
{/* Light themes */} {/* Light themes */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-xs font-medium text-muted uppercase tracking-wide">Light</label> <label className="block text-xs font-medium text-soft uppercase tracking-wide">Light</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{lightThemes.map((t) => { {lightThemes.map((t) => {
const colors = getThemeColors(t.id); const colors = getThemeColors(t.id);
const isActive = theme === t.id; const isActive = theme === t.id;
return ( return (
<button <ThemeChoice
key={t.id} key={t.id}
label={t.name}
colors={colors}
selected={isActive}
onClick={() => setTheme(t.id)} onClick={() => setTheme(t.id)}
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border />
${isActive
? 'border-accent'
: 'border-offbase hover:border-muted'
}`}
style={{ backgroundColor: colors.base }}
>
{isActive ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-accent" />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
{t.name}
</span>
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
</button>
); );
})} })}
</div> </div>
@ -1085,41 +1054,19 @@ export function SettingsModal({
{/* Dark themes */} {/* Dark themes */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-xs font-medium text-muted uppercase tracking-wide">Dark</label> <label className="block text-xs font-medium text-soft uppercase tracking-wide">Dark</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
{darkThemes.map((t) => { {darkThemes.map((t) => {
const colors = getThemeColors(t.id); const colors = getThemeColors(t.id);
const isActive = theme === t.id; const isActive = theme === t.id;
return ( return (
<button <ThemeChoice
key={t.id} key={t.id}
label={t.name}
colors={colors}
selected={isActive}
onClick={() => setTheme(t.id)} onClick={() => setTheme(t.id)}
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border />
${isActive
? 'border-accent'
: 'border-offbase hover:border-muted'
}`}
style={{ backgroundColor: colors.base }}
>
{isActive ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-accent" />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
{t.name}
</span>
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
</button>
); );
})} })}
</div> </div>
@ -1135,7 +1082,8 @@ export function SettingsModal({
<Button <Button
onClick={handleImportLibrary} onClick={handleImportLibrary}
disabled={isBusy} disabled={isBusy}
className={buttonClass({ variant: 'outline', size: 'md' })} variant="outline"
size="md"
> >
{isImportingLibrary ? `Importing... ${Math.round(progress)}%` : 'Import from library'} {isImportingLibrary ? `Importing... ${Math.round(progress)}%` : 'Import from library'}
</Button> </Button>
@ -1147,14 +1095,16 @@ export function SettingsModal({
<Button <Button
onClick={handleRefresh} onClick={handleRefresh}
disabled={isBusy} disabled={isBusy}
className={buttonClass({ variant: 'outline', size: 'md' })} variant="outline"
size="md"
> >
Refresh Refresh
</Button> </Button>
<Button <Button
onClick={handleClearCache} onClick={handleClearCache}
disabled={isBusy} disabled={isBusy}
className={buttonClass({ variant: 'outline', size: 'md' })} variant="outline"
size="md"
> >
Clear cache Clear cache
</Button> </Button>
@ -1166,30 +1116,16 @@ export function SettingsModal({
{/* Admin Section */} {/* Admin Section */}
{activeSection === 'admin' && isAdmin && ( {activeSection === 'admin' && isAdmin && (
<div className="space-y-4"> <div className="space-y-4">
<div <SegmentedControl
role="radiogroup" value={adminSubTab}
aria-label="Admin tab" options={[
className={`${segmentedGroupClass} grid-cols-2`} { value: 'providers', label: 'Shared providers' },
> { value: 'features', label: 'Site features' },
{([ ]}
{ id: 'providers', label: 'Shared providers' }, onChange={setAdminSubTab}
{ id: 'features', label: 'Site features' }, ariaLabel="Admin tab"
] as { id: AdminSubTab; label: string }[]).map((tab) => { className="grid-cols-2"
const active = adminSubTab === tab.id; />
return (
<button
key={tab.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => setAdminSubTab(tab.id)}
className={segmentedButtonClass(active)}
>
{tab.label}
</button>
);
})}
</div>
{adminSubTab === 'providers' && <AdminProvidersPanel />} {adminSubTab === 'providers' && <AdminProvidersPanel />}
{adminSubTab === 'features' && <AdminFeaturesPanel />} {adminSubTab === 'features' && <AdminFeaturesPanel />}
</div> </div>
@ -1199,10 +1135,10 @@ export function SettingsModal({
{activeSection === 'account' && ( {activeSection === 'account' && (
<div className="space-y-2"> <div className="space-y-2">
{/* Session info */} {/* Session info */}
<div className="rounded-lg bg-background border border-offbase p-4 space-y-2"> <div className="rounded-lg bg-background border border-line p-4 space-y-2">
<h4 className="text-sm font-medium text-foreground">Current Session</h4> <h4 className="text-sm font-medium text-foreground">Current Session</h4>
<div className="text-sm space-y-1"> <div className="text-sm space-y-1">
<p className="text-muted">Logged in as:</p> <p className="text-soft">Logged in as:</p>
{session?.user ? ( {session?.user ? (
<> <>
<p className="font-medium text-foreground"> <p className="font-medium text-foreground">
@ -1211,7 +1147,7 @@ export function SettingsModal({
: (session.user.name || session.user.email || 'Account')} : (session.user.name || session.user.email || 'Account')}
</p> </p>
{!session.user.isAnonymous && ( {!session.user.isAnonymous && (
<p className="text-xs text-muted font-mono">{session.user.email}</p> <p className="text-xs text-soft font-mono">{session.user.email}</p>
)} )}
{session.user.isAnonymous && ( {session.user.isAnonymous && (
<p className="text-xs text-accent mt-1">Anonymous session</p> <p className="text-xs text-accent mt-1">Anonymous session</p>
@ -1225,20 +1161,20 @@ export function SettingsModal({
{/* Export Data */} {/* Export Data */}
{session?.user && ( {session?.user && (
<button <ChoiceTile
onClick={() => { onClick={() => {
window.open('/api/user/export', '_blank'); window.open('/api/user/export', '_blank');
}} }}
className="w-full rounded-lg border border-offbase bg-background p-4 flex items-center gap-4 hover:bg-offbase transition-colors text-left group" className="w-full rounded-lg bg-background p-4 text-left hover:bg-accent-wash"
> >
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-offbase flex items-center justify-center group-hover:bg-background transition-colors"> <div className="flex-shrink-0 w-10 h-10 rounded-lg bg-surface-sunken flex items-center justify-center">
<DownloadIcon className="w-5 h-5 text-accent" /> <DownloadIcon className="w-5 h-5 text-accent" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Export My Data</p> <p className="text-sm font-medium text-foreground">Export My Data</p>
<p className="text-xs text-muted">Download all your data as a ZIP file</p> <p className="text-xs text-soft">Download all your data as a ZIP file</p>
</div> </div>
</button> </ChoiceTile>
)} )}
{/* Actions */} {/* Actions */}
@ -1247,27 +1183,31 @@ export function SettingsModal({
<> <>
<Button <Button
onClick={handleSignOut} onClick={handleSignOut}
className={buttonClass({ variant: 'outline', size: 'md', className: 'hover:scale-[1.04]' })} variant="outline"
size="md"
> >
Disconnect account Disconnect account
</Button> </Button>
<div className="pt-4 mt-4 border-t border-offbase"> {enableDestructiveDelete && (
<label className="block text-sm font-medium text-red-500 mb-2">Danger Zone</label> <div className="pt-4 mt-4 border-t border-line-soft">
<Button <label className="block text-sm font-medium text-danger mb-2">Danger Zone</label>
onClick={() => setShowDeleteAccountConfirm(true)} <Button
className={buttonClass({ variant: 'danger', size: 'md' })} onClick={() => setShowDeleteAccountConfirm(true)}
> variant="danger"
Delete Account size="md"
</Button> >
<p className="text-xs text-muted mt-2"> Delete Account
Permanently deletes your account and all data. </Button>
</p> <p className="text-xs text-soft mt-2">
</div> Permanently deletes your account and all data.
</p>
</div>
)}
</> </>
) : ( ) : (
<div className="pt-2 border-t border-offbase"> <div className="pt-2 border-t border-line-soft">
<p className="text-sm text-muted mb-3"> <p className="text-sm text-soft mb-3">
{session?.user?.isAnonymous {session?.user?.isAnonymous
? (runtimeConfig.enableUserSignups ? (runtimeConfig.enableUserSignups
? 'You are using an anonymous session. Sign up to save your progress permanently, your current data is automatically transferred.' ? 'You are using an anonymous session. Sign up to save your progress permanently, your current data is automatically transferred.'
@ -1278,19 +1218,19 @@ export function SettingsModal({
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Link href="/signin"> <Link href="/signin">
<Button className={buttonClass({ variant: 'outline', size: 'md', className: 'hover:scale-[1.04]' })}> <Button variant="outline" size="md">
Connect Connect
</Button> </Button>
</Link> </Link>
{runtimeConfig.enableUserSignups && ( {runtimeConfig.enableUserSignups && (
<Link href="/signup"> <Link href="/signup">
<Button className={buttonClass({ variant: 'primary', size: 'md', className: 'hover:scale-[1.04]' })}> <Button variant="primary" size="md">
Create account Create account
</Button> </Button>
</Link> </Link>
)} )}
<Link href="/?redirect=false"> <Link href="/?redirect=false">
<Button className={buttonClass({ variant: 'outline', size: 'md', className: 'hover:scale-[1.04]' })}> <Button variant="outline" size="md">
Back to landing page Back to landing page
</Button> </Button>
</Link> </Link>
@ -1304,12 +1244,7 @@ export function SettingsModal({
</div> </div>
</> </>
)} )}
</DialogPanel> </ModalFrame>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
<ConfirmDialog <ConfirmDialog
isOpen={showDeleteDocsConfirm} isOpen={showDeleteDocsConfirm}
@ -1435,21 +1370,20 @@ function SettingsChangelogPanel({
}, [expanded, manifest, manifestUrl, bodies]); }, [expanded, manifest, manifestUrl, bodies]);
return ( return (
<div className="h-[490px] flex flex-col bg-base"> <div className="h-[490px] flex flex-col bg-surface">
<div className="flex items-center gap-3 px-4 py-3 border-b border-offbase bg-background"> <div className="flex items-center gap-3 px-4 py-3 border-b border-line-soft bg-background">
<Button <IconButton
onClick={onClose} onClick={onClose}
className="inline-flex items-center justify-center rounded-md text-muted hover:text-accent hover:bg-base transition-all duration-200 ease-in-out transform hover:scale-[1.01]"
aria-label="Back to settings" aria-label="Back to settings"
title="Back" title="Back"
> >
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3 h-3" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
</Button> </IconButton>
<div className="min-w-0"> <div className="min-w-0">
<h4 className="text-sm font-semibold text-foreground">Changelog</h4> <h4 className="text-sm font-semibold text-foreground">Changelog</h4>
<p className="text-xs text-muted truncate"> <p className="text-xs text-soft truncate">
{normalizedAppVersion {normalizedAppVersion
? `Current version: v${normalizedAppVersion}` ? `Current version: v${normalizedAppVersion}`
: 'Release history from GitHub'} : 'Release history from GitHub'}
@ -1459,20 +1393,20 @@ function SettingsChangelogPanel({
<div className="flex-1 overflow-y-auto px-3 pb-3"> <div className="flex-1 overflow-y-auto px-3 pb-3">
{loading && ( {loading && (
<div className="py-3 text-sm text-muted"> <div className="py-3 text-sm text-soft">
Loading changelog Loading changelog
</div> </div>
)} )}
{!loading && error && ( {!loading && error && (
<div className="py-3 space-y-2 border-b border-offbase"> <div className="py-3 space-y-2 border-b border-line-soft">
<p className="text-sm text-foreground">Could not load changelog right now.</p> <p className="text-sm text-foreground">Could not load changelog right now.</p>
<p className="text-xs text-muted break-words">{error}</p> <p className="text-xs text-soft break-words">{error}</p>
<a <a
href="https://github.com/richardr1126/openreader/releases" href="https://github.com/richardr1126/openreader/releases"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex text-xs font-medium text-accent hover:underline transition-all duration-200 ease-in-out transform hover:scale-[1.02]" className="inline-flex text-xs font-medium text-accent hover:underline transition duration-base ease-standard transform"
> >
Open GitHub Releases Open GitHub Releases
</a> </a>
@ -1480,7 +1414,7 @@ function SettingsChangelogPanel({
)} )}
{!loading && !error && manifest.length === 0 && ( {!loading && !error && manifest.length === 0 && (
<div className="py-3 text-sm text-muted"> <div className="py-3 text-sm text-soft">
No releases found. No releases found.
</div> </div>
)} )}
@ -1493,35 +1427,35 @@ function SettingsChangelogPanel({
const normalizedName = normalizeVersion(entry.name || ''); const normalizedName = normalizeVersion(entry.name || '');
const showName = Boolean(entry.name) && normalizedName !== normalizedTag; const showName = Boolean(entry.name) && normalizedName !== normalizedTag;
return ( return (
<div key={entry.tag_name} className="border-b border-offbase"> <div key={entry.tag_name} className="border-b border-line-soft">
<button <button
type="button" type="button"
onClick={() => setExpanded((prev) => ({ ...prev, [entry.tag_name]: !isExpanded }))} onClick={() => setExpanded((prev) => ({ ...prev, [entry.tag_name]: !isExpanded }))}
className="w-full text-left py-2 flex items-center gap-2 hover:bg-base transition-all duration-200 ease-in-out transform hover:scale-[1.01]" className="w-full rounded-md border border-transparent px-2 py-2 text-left flex items-center gap-2 transition duration-base ease-standard hover:border-accent-line hover:bg-accent-wash"
> >
<ChevronRightIcon <ChevronRightIcon
className={`w-3.5 h-3.5 shrink-0 text-muted transition-transform ${ className={`w-3.5 h-3.5 shrink-0 text-soft transition-transform ${
isExpanded ? 'rotate-90 text-foreground' : '' isExpanded ? 'rotate-90 text-foreground' : ''
}`} }`}
/> />
<div className="min-w-0 flex items-center gap-2 text-sm w-full"> <div className="min-w-0 flex items-center gap-2 text-sm w-full">
<span className="font-semibold text-foreground shrink-0">{entry.tag_name}</span> <span className="font-semibold text-foreground shrink-0">{entry.tag_name}</span>
{entry.prerelease && ( {entry.prerelease && (
<span className="text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 bg-offbase text-muted shrink-0"> <span className="text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 bg-surface-sunken text-soft shrink-0">
prerelease prerelease
</span> </span>
)} )}
{isCurrent && ( {isCurrent && (
<span className="text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 bg-offbase text-accent shrink-0"> <span className="text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 bg-surface-sunken text-accent shrink-0">
current current
</span> </span>
)} )}
{showName && ( {showName && (
<span className="text-xs text-muted truncate"> <span className="text-xs text-soft truncate">
{entry.name} {entry.name}
</span> </span>
)} )}
<span className="text-[11px] text-muted shrink-0"> <span className="text-[11px] text-soft shrink-0">
{new Date(entry.published_at).toLocaleDateString()} {new Date(entry.published_at).toLocaleDateString()}
</span> </span>
</div> </div>
@ -1530,19 +1464,19 @@ function SettingsChangelogPanel({
{isExpanded && ( {isExpanded && (
<div className="pl-6 pr-1 pb-3 pt-1 space-y-2"> <div className="pl-6 pr-1 pb-3 pt-1 space-y-2">
{body ? ( {body ? (
<div className="text-sm text-foreground leading-6 space-y-2 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_ul]:pl-5 [&_ol]:pl-5 [&_code]:bg-offbase [&_code]:rounded [&_code]:px-1 [&_pre]:bg-offbase [&_pre]:rounded [&_pre]:p-2 [&_pre]:overflow-x-auto"> <div className="text-sm text-foreground leading-6 space-y-2 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_ul]:pl-5 [&_ol]:pl-5 [&_code]:bg-surface-sunken [&_code]:rounded [&_code]:px-1 [&_pre]:bg-surface-sunken [&_pre]:rounded [&_pre]:p-2 [&_pre]:overflow-x-auto">
<ReactMarkdown remarkPlugins={[remarkGfm]}> <ReactMarkdown remarkPlugins={[remarkGfm]}>
{body.body || '_No release notes provided._'} {body.body || '_No release notes provided._'}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
) : ( ) : (
<p className="text-xs text-muted">Loading release notes</p> <p className="text-xs text-soft">Loading release notes</p>
)} )}
<a <a
href={entry.html_url} href={entry.html_url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex text-xs font-medium text-accent hover:underline transition-all duration-200 ease-in-out transform hover:scale-[1.02]" className="inline-flex text-xs font-medium text-accent hover:underline transition duration-base ease-standard transform"
> >
View on GitHub View on GitHub
</a> </a>

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { Fragment, useEffect, useMemo, useState } from 'react'; import { Fragment, useEffect, useMemo, useState } from 'react';
import { Button, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react'; import { Listbox, Transition } from '@headlessui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
@ -10,11 +10,11 @@ import {
Section, Section,
ToggleRow, ToggleRow,
inputClass, inputClass,
listboxButtonClass, SharedListboxButton,
listboxOptionClass, SharedListboxOption,
listboxOptionsClass, SharedListboxOptions,
} from '@/components/formPrimitives'; Button,
import { buttonClass } from '@/components/ui/buttonPrimitives'; } from '@/components/ui';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders'; import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
@ -205,24 +205,23 @@ export function AdminFeaturesPanel() {
</div> </div>
{providerOptions.length > 0 ? ( {providerOptions.length > 0 ? (
<Listbox value={selectedProviderOption} onChange={handleProviderChange}> <Listbox value={selectedProviderOption} onChange={handleProviderChange}>
<ListboxButton className={listboxButtonClass}> <SharedListboxButton>
<span className="block truncate">{selectedProviderOption?.name ?? 'Select provider'}</span> <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"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" /> <ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span> </span>
</ListboxButton> </SharedListboxButton>
<Transition <Transition
as={Fragment} as={Fragment}
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}> <SharedListboxOptions anchor="bottom start">
{providerOptions.map((opt) => ( {providerOptions.map((opt) => (
<ListboxOption <SharedListboxOption
key={opt.id} key={opt.id}
value={opt} value={opt}
className={({ active }) => listboxOptionClass(active)}
> >
{({ selected }) => ( {({ selected }) => (
<> <>
@ -236,9 +235,9 @@ export function AdminFeaturesPanel() {
)} )}
</> </>
)} )}
</ListboxOption> </SharedListboxOption>
))} ))}
</ListboxOptions> </SharedListboxOptions>
</Transition> </Transition>
</Listbox> </Listbox>
) : ( ) : (
@ -581,14 +580,16 @@ export function AdminFeaturesPanel() {
<Button <Button
onClick={discardAll} onClick={discardAll}
disabled={dirty.size === 0 || saving} disabled={dirty.size === 0 || saving}
className={buttonClass({ variant: 'secondary', size: 'sm' })} variant="secondary"
size="sm"
> >
Discard Discard
</Button> </Button>
<Button <Button
onClick={saveAll} onClick={saveAll}
disabled={dirty.size === 0 || saving} disabled={dirty.size === 0 || saving}
className={buttonClass({ variant: 'primary', size: 'sm' })} variant="primary"
size="sm"
> >
{saving ? 'Saving…' : dirty.size > 0 ? `Save (${dirty.size})` : 'Save'} {saving ? 'Saving…' : dirty.size > 0 ? `Save (${dirty.size})` : 'Save'}
</Button> </Button>
@ -654,14 +655,16 @@ function SourceBadge({
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{canReset && !dirty && ( {canReset && !dirty && (
<button <Button
type="button" type="button"
variant="ghost"
size="xs"
onClick={onReset} onClick={onReset}
disabled={saving} disabled={saving}
className="text-[11px] font-medium text-muted hover:text-accent transition-colors disabled:opacity-50" className="h-auto px-1 py-0 text-[11px] font-medium text-muted hover:text-accent"
> >
Reset Reset
</button> </Button>
)} )}
{dirty ? ( {dirty ? (
<Badge tone="accent">Modified</Badge> <Badge tone="accent">Modified</Badge>

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Input, 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 { Fragment } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@ -14,11 +14,15 @@ import {
Section, Section,
ToggleRow, ToggleRow,
inputClass, inputClass,
listboxButtonClass, SharedListboxButton,
listboxOptionClass, SharedListboxOption,
listboxOptionsClass, SharedListboxOptions,
} from '@/components/formPrimitives'; Button,
import { buttonClass } from '@/components/ui/buttonPrimitives'; IconButton,
Input,
MenuItemsSurface,
MenuActionItem,
} from '@/components/ui';
type ProviderType = TtsProviderId; type ProviderType = TtsProviderId;
@ -364,7 +368,8 @@ export function AdminProvidersPanel() {
{!editingId ? ( {!editingId ? (
<Button <Button
onClick={startCreate} onClick={startCreate}
className={buttonClass({ variant: 'primary', size: 'icon', className: 'h-7 w-7' })} variant="primary"
size="icon"
aria-label="Add provider" aria-label="Add provider"
title="Add provider" title="Add provider"
> >
@ -419,24 +424,23 @@ export function AdminProvidersPanel() {
setCustomModelInput(''); setCustomModelInput('');
}} }}
> >
<ListboxButton className={listboxButtonClass}> <SharedListboxButton>
<span className="block truncate">{selectedProviderType.label}</span> <span className="block truncate">{selectedProviderType.label}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" /> <ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span> </span>
</ListboxButton> </SharedListboxButton>
<Transition <Transition
as={Fragment} as={Fragment}
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}> <SharedListboxOptions anchor="bottom start">
{PROVIDER_TYPE_OPTIONS.map((opt) => ( {PROVIDER_TYPE_OPTIONS.map((opt) => (
<ListboxOption <SharedListboxOption
key={opt.value} key={opt.value}
value={opt} value={opt}
className={({ active }) => listboxOptionClass(active)}
> >
{({ selected }) => ( {({ selected }) => (
<> <>
@ -450,9 +454,9 @@ export function AdminProvidersPanel() {
)} )}
</> </>
)} )}
</ListboxOption> </SharedListboxOption>
))} ))}
</ListboxOptions> </SharedListboxOptions>
</Transition> </Transition>
</Listbox> </Listbox>
</Field> </Field>
@ -478,26 +482,25 @@ export function AdminProvidersPanel() {
setCustomModelInput(''); setCustomModelInput('');
}} }}
> >
<ListboxButton className={listboxButtonClass}> <SharedListboxButton>
<span className="block truncate"> <span className="block truncate">
{selectedModelDefinition?.name ?? 'Select model'} {selectedModelDefinition?.name ?? 'Select model'}
</span> </span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" /> <ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span> </span>
</ListboxButton> </SharedListboxButton>
<Transition <Transition
as={Fragment} as={Fragment}
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}> <SharedListboxOptions anchor="bottom start">
{modelDefinitions.map((model) => ( {modelDefinitions.map((model) => (
<ListboxOption <SharedListboxOption
key={model.id} key={model.id}
value={model.id} value={model.id}
className={({ active }) => listboxOptionClass(active)}
> >
{({ selected }) => ( {({ selected }) => (
<> <>
@ -516,9 +519,9 @@ export function AdminProvidersPanel() {
)} )}
</> </>
)} )}
</ListboxOption> </SharedListboxOption>
))} ))}
</ListboxOptions> </SharedListboxOptions>
</Transition> </Transition>
</Listbox> </Listbox>
{supportsCustomModel && selectedModelId === 'custom' && ( {supportsCustomModel && selectedModelId === 'custom' && (
@ -589,13 +592,14 @@ export function AdminProvidersPanel() {
/> />
<div className="pt-1 flex justify-end gap-2"> <div className="pt-1 flex justify-end gap-2">
<Button onClick={cancelEdit} className={buttonClass({ variant: 'secondary', size: 'sm' })}> <Button onClick={cancelEdit} variant="secondary" size="sm">
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={submit} onClick={submit}
disabled={submitDisabled} disabled={submitDisabled}
className={buttonClass({ variant: 'primary', size: 'sm' })} variant="primary"
size="sm"
> >
{saveMutation.isPending ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'} {saveMutation.isPending ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'}
</Button> </Button>
@ -637,7 +641,9 @@ export function AdminProvidersPanel() {
</div> </div>
<Menu as="div" className="relative shrink-0"> <Menu as="div" className="relative shrink-0">
<MenuButton <MenuButton
className="inline-flex items-center justify-center h-7 w-7 rounded-md border border-offbase bg-base text-muted hover:text-accent hover:bg-offbase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50" as={IconButton}
tone="surface"
size="sm"
title="Provider actions" title="Provider actions"
aria-label="Provider actions" aria-label="Provider actions"
disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending} disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending}
@ -653,56 +659,26 @@ export function AdminProvidersPanel() {
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<MenuItems <MenuItemsSurface
anchor="bottom end" anchor="bottom end"
className="z-50 mt-2 min-w-[170px] rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none p-1" className="z-50 mt-2 min-w-[170px] bg-base focus:outline-none"
> >
<MenuItem> <MenuActionItem onClick={() => startEdit(p)}>
{({ active }) => ( Edit
<button </MenuActionItem>
type="button" <MenuActionItem
onClick={() => startEdit(p)} onClick={() => setDefault(p.slug)}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`} disabled={!p.enabled || defaultProviderSlug === p.slug}
> >
Edit Set as default
</button> </MenuActionItem>
)} <MenuActionItem onClick={() => toggleEnabled(p)}>
</MenuItem> {p.enabled ? 'Disable' : 'Enable'}
<MenuItem disabled={!p.enabled || defaultProviderSlug === p.slug}> </MenuActionItem>
{({ active, disabled }) => ( <MenuActionItem tone="danger" onClick={() => remove(p.id)}>
<button Delete
type="button" </MenuActionItem>
onClick={() => setDefault(p.slug)} </MenuItemsSurface>
disabled={disabled}
className={`${disabled ? 'text-muted/60 cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
Set as default
</button>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={() => toggleEnabled(p)}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
{p.enabled ? 'Disable' : 'Enable'}
</button>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={() => remove(p.id)}
className={`${active ? 'bg-offbase' : ''} text-red-500 flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
Delete
</button>
)}
</MenuItem>
</MenuItems>
</Transition> </Transition>
</Menu> </Menu>
</div> </div>

View file

@ -1,23 +0,0 @@
export {
Badge,
Card,
Field,
Section,
ToggleRow,
inputClass,
listboxButtonClass,
listboxOptionClass,
listboxOptionsClass,
segmentedButtonClass,
segmentedGroupClass,
} from '@/components/formPrimitives';
export {
buttonClass,
btnBase,
btnDanger,
btnGhost,
btnOutline,
btnPrimary,
btnSecondary,
} from '@/components/ui/buttonPrimitives';

View file

@ -7,7 +7,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/client/auth-client'; import { getAuthClient } from '@/lib/client/auth-client';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { Button } from '@/components/ui';
function sleep(ms: number) { function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms)); return new Promise<void>((resolve) => setTimeout(resolve, ms));
@ -252,25 +252,25 @@ export function AuthLoader({ children }: { children: ReactNode }) {
if (isLoading) { if (isLoading) {
return ( return (
<div className="fixed inset-0 bg-base z-50 flex flex-col items-center justify-center gap-4"> <div className="fixed inset-0 bg-surface z-50 flex flex-col items-center justify-center gap-4">
<LoadingSpinner className="w-8 h-8 text-accent" /> <LoadingSpinner className="w-8 h-8 text-accent" />
{bootstrapError ? ( {bootstrapError ? (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<p className="text-sm text-muted text-center">{bootstrapError}</p> <p className="text-sm text-soft text-center">{bootstrapError}</p>
<button <Button
type="button"
onClick={() => { onClick={() => {
attemptedForNullSessionRef.current = false; attemptedForNullSessionRef.current = false;
setBootstrapError(null); setBootstrapError(null);
setRetryNonce((v) => v + 1); setRetryNonce((v) => v + 1);
}} }}
className={buttonClass({ variant: 'primary', size: 'sm' })} variant="primary"
size="sm"
> >
Retry Retry
</button> </Button>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted animate-pulse"> <p className="text-sm text-soft animate-pulse">
{isAutoLoggingIn ? 'Starting anonymous session...' : 'Loading...'} {isAutoLoggingIn ? 'Starting anonymous session...' : 'Loading...'}
</p> </p>
)} )}

View file

@ -1,16 +1,9 @@
'use client'; 'use client';
import { Fragment, useState } from 'react'; import { useState } from 'react';
import {
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
Button,
} from '@headlessui/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Button, ModalFrame, ModalTitle } from '@/components/ui';
export type ClaimableCounts = { export type ClaimableCounts = {
documents: number; documents: number;
@ -74,91 +67,48 @@ export default function ClaimDataModal({
}; };
return ( return (
<Transition appear show={isOpen} as={Fragment}> <ModalFrame open={isOpen} onClose={onDismiss} panelTestId="claim-modal" className="z-[80]">
<Dialog as="div" className="relative z-[80]" onClose={onDismiss}> <ModalTitle className="mb-4">Existing Data Found</ModalTitle>
<TransitionChild
as={Fragment} <p className="text-sm text-soft mb-2">
enter="ease-out duration-300" We found existing anonymous data from before auth was enabled.
enterFrom="opacity-0" Claim it now to attach it to your account.
enterTo="opacity-100" </p>
leave="ease-in duration-200"
leaveFrom="opacity-100" <div className="mb-4 rounded-lg border border-line bg-surface-sunken p-3">
leaveTo="opacity-0" <div className="text-xs font-semibold uppercase tracking-wide text-soft">Claimable data</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-soft">
<li>{claimableCounts.documents} document(s)</li>
<li>{claimableCounts.audiobooks} audiobook(s)</li>
<li>{claimableCounts.preferences} preference set(s)</li>
<li>{claimableCounts.progress} reading progress record(s)</li>
</ul>
</div>
<p className="text-xs text-faint mb-6 italic">
First user to claim this data will own it and revoke access for anyone else.
</p>
<div className="flex justify-end gap-3">
<Button
data-testid="claim-dismiss-button"
variant="outline"
size="sm"
onClick={onDismiss}
disabled={isClaiming}
> >
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" /> Dismiss
</TransitionChild> </Button>
<Button
<div className="fixed inset-0 overflow-y-auto"> data-testid="claim-submit-button"
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4"> variant="primary"
<TransitionChild size="sm"
as={Fragment} onClick={handleClaim}
enter="ease-out duration-300" disabled={isClaiming}
enterFrom="opacity-0 scale-95" >
enterTo="opacity-100 scale-100" {isClaiming ? 'Claiming...' : 'Claim Data'}
leave="ease-in duration-200" </Button>
leaveFrom="opacity-100 scale-100" </div>
leaveTo="opacity-0 scale-95" </ModalFrame>
>
<DialogPanel data-testid="claim-modal" className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground mb-4"
>
Existing Data Found
</DialogTitle>
<p className="text-sm text-muted mb-2">
We found existing anonymous data from before auth was enabled.
Claim it now to attach it to your account.
</p>
<div className="mb-4 rounded-lg border border-offbase bg-offbase/40 p-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Claimable data</div>
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-foreground/90">
<li>{claimableCounts.documents} document(s)</li>
<li>{claimableCounts.audiobooks} audiobook(s)</li>
<li>{claimableCounts.preferences} preference set(s)</li>
<li>{claimableCounts.progress} reading progress record(s)</li>
</ul>
</div>
<p className="text-xs text-muted/70 mb-6 italic">
First user to claim this data will own it and revoke access for anyone else.
</p>
<div className="flex justify-end gap-3">
<Button
data-testid="claim-dismiss-button"
type="button"
onClick={onDismiss}
disabled={isClaiming}
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
Dismiss
</Button>
<Button
data-testid="claim-submit-button"
type="button"
onClick={handleClaim}
disabled={isClaiming}
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background
disabled:opacity-50"
>
{isClaiming ? 'Claiming...' : 'Claim Data'}
</Button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
); );
} }

View file

@ -19,13 +19,13 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
const isAnonymous = status.userType === 'anonymous'; const isAnonymous = status.userType === 'anonymous';
return ( return (
<div className={`bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2 ${className}`}> <div className={`bg-accent-wash border border-accent-line rounded-lg px-3 py-2 ${className}`}>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2"> <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div className="text-xs sm:text-sm"> <div className="text-xs sm:text-sm">
<span className="font-medium text-amber-700 dark:text-amber-400"> <span className="font-medium text-accent ">
Daily TTS limit reached. Daily TTS limit reached.
</span> </span>
<span className="text-amber-600 dark:text-amber-500 ml-1.5"> <span className="text-accent ml-1.5">
{`Used ${formatCharCount(status.currentCount)} / ${formatCharCount(status.limit)} characters.`} {`Used ${formatCharCount(status.currentCount)} / ${formatCharCount(status.limit)} characters.`}
{' Resets in '}{timeUntilReset}. {' Resets in '}{timeUntilReset}.
</span> </span>
@ -36,7 +36,7 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
href="/signup" href="/signup"
className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-md className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-md
bg-accent text-background hover:bg-secondary-accent bg-accent text-background hover:bg-secondary-accent
transform transition-transform duration-200 hover:scale-[1.04]" transform transition-transform duration-base"
> >
Sign up for a higher limit Sign up for a higher limit
</Link> </Link>
@ -64,7 +64,7 @@ export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) {
if (isAtLimit) { if (isAtLimit) {
return ( return (
<span className={`text-xs font-medium text-amber-600 dark:text-amber-400 ${className}`}> <span className={`text-xs font-medium text-accent ${className}`}>
Limit reached Limit reached
</span> </span>
); );
@ -72,7 +72,7 @@ export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) {
if (isWarning) { if (isWarning) {
return ( return (
<span className={`text-xs text-muted ${className}`}> <span className={`text-xs text-soft ${className}`}>
{formatCharCount(status.remainingChars)} chars left {formatCharCount(status.remainingChars)} chars left
</span> </span>
); );

View file

@ -1,6 +1,5 @@
'use client'; 'use client';
import { Button } from '@headlessui/react';
import Link from 'next/link'; import Link from 'next/link';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
@ -8,6 +7,7 @@ import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/client/auth-client'; import { getAuthClient } from '@/lib/client/auth-client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { UserIcon } from '@/components/icons/Icons'; import { UserIcon } from '@/components/icons/Icons';
import { ButtonLink, IconButton, SidebarNavItem, SidebarNavLink } from '@/components/ui';
type UserMenuVariant = 'toolbar' | 'sidebar'; type UserMenuVariant = 'toolbar' | 'sidebar';
@ -31,21 +31,24 @@ export function UserMenu({
router.push('/signin'); router.push('/signin');
}; };
const rowClass =
'w-full inline-flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border border-transparent transition-all duration-200 ease-out text-left hover:scale-[1.01] hover:border-accent hover:bg-offbase hover:text-accent';
if (!session || session.user.isAnonymous) { if (!session || session.user.isAnonymous) {
if (variant === 'sidebar') { if (variant === 'sidebar') {
return ( return (
<div className={`flex w-full flex-col gap-0.5 ${className}`}> <div className={`flex w-full flex-col gap-0.5 ${className}`}>
<Link href="/signin" className={rowClass}> <Link href="/signin" legacyBehavior passHref>
<UserIcon className="h-3.5 w-3.5 text-muted" /> <SidebarNavLink
<span className="truncate">Connect</span> compact
icon={<UserIcon className="h-3.5 w-3.5" />}
label="Connect"
/>
</Link> </Link>
{enableUserSignups && ( {enableUserSignups && (
<Link href="/signup" className={rowClass}> <Link href="/signup" legacyBehavior passHref>
<UserIcon className="h-3.5 w-3.5 text-muted" /> <SidebarNavLink
<span className="truncate">Create account</span> compact
icon={<UserIcon className="h-3.5 w-3.5" />}
label="Create account"
/>
</Link> </Link>
)} )}
</div> </div>
@ -54,17 +57,13 @@ export function UserMenu({
return ( return (
<div className={`flex gap-2 ${className}`}> <div className={`flex gap-2 ${className}`}>
<Link href="/signin"> <ButtonLink href="/signin" variant="secondary" size="sm">
<Button className="inline-flex items-center rounded-md bg-base border border-offbase px-2 py-1 text-xs font-medium text-foreground hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.01] hover:text-accent"> Connect
Connect </ButtonLink>
</Button>
</Link>
{enableUserSignups && ( {enableUserSignups && (
<Link href="/signup"> <ButtonLink href="/signup" variant="primary" size="sm">
<Button className="inline-flex items-center rounded-md bg-accent px-2 py-1 text-xs font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.01]"> Create account
Create account </ButtonLink>
</Button>
</Link>
)} )}
</div> </div>
); );
@ -72,40 +71,44 @@ export function UserMenu({
if (variant === 'sidebar') { if (variant === 'sidebar') {
return ( return (
<button <SidebarNavItem
compact
onClick={handleDisconnectAccount} onClick={handleDisconnectAccount}
className={`${rowClass} ${className}`} className={className}
title="Disconnect account" title="Disconnect account"
aria-label="Disconnect account" aria-label="Disconnect account"
> icon={<UserIcon className="h-3.5 w-3.5" />}
<UserIcon className="h-3.5 w-3.5 text-muted" /> label={session.user.email || 'Account'}
<span className="truncate flex-1">{session.user.email || 'Account'}</span> trailing={(
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg className="h-3.5 w-3.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline> <polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line> <line x1="21" y1="12" x2="9" y2="12"></line>
</svg> </svg>
</button> )}
/>
); );
} }
return ( return (
<div className={`flex items-center gap-2 px-2 py-1 rounded-md border border-offbase bg-base ${className}`}> <div className={`flex h-7 items-center gap-1.5 rounded-md border border-line bg-surface px-2 ${className}`}>
<span className="hidden sm:block text-xs font-medium text-foreground truncate max-w-[160px]"> <span className="hidden max-w-[150px] truncate text-[11px] font-medium text-foreground sm:block">
{session.user.email || 'Account'} {session.user.email || 'Account'}
</span> </span>
<Button <IconButton
onClick={handleDisconnectAccount} onClick={handleDisconnectAccount}
className="inline-flex items-center text-foreground text-xs hover:text-accent transform transition-all duration-200 ease-in-out hover:scale-[1.01]"
title="Disconnect account" title="Disconnect account"
aria-label="Disconnect account"
size="xs"
className="-mr-0.5"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline> <polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line> <line x1="21" y1="12" x2="9" y2="12"></line>
</svg> </svg>
</Button> </IconButton>
</div> </div>
); );
} }

View file

@ -1,5 +1,5 @@
import { Fragment, KeyboardEvent } from 'react'; import { KeyboardEvent } from 'react';
import { Dialog, DialogPanel, DialogTitle, Input, Transition, TransitionChild } from '@headlessui/react'; import { Input, ModalFrame, ModalTitle } from '@/components/ui';
interface CreateFolderDialogProps { interface CreateFolderDialogProps {
isOpen: boolean; isOpen: boolean;
@ -17,52 +17,20 @@ export function CreateFolderDialog({
onClose, onClose,
}: CreateFolderDialogProps) { }: CreateFolderDialogProps) {
return ( return (
<Transition appear show={isOpen} as={Fragment}> <ModalFrame open={isOpen} onClose={onClose}>
<Dialog as="div" className="relative z-50" onClose={onClose}> <ModalTitle>Create New Folder</ModalTitle>
<TransitionChild <div className="mt-4">
as={Fragment} <Input
enter="ease-out duration-300" type="text"
enterFrom="opacity-0" value={folderName}
enterTo="opacity-100" onChange={(e) => onFolderNameChange(e.target.value)}
leave="ease-in duration-200" onKeyDown={onKeyDown}
leaveFrom="opacity-100" placeholder="Enter folder name"
leaveTo="opacity-0" controlSize="lg"
> autoFocus
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" /> />
</TransitionChild> <p className="mt-2 text-xs text-soft">Press Enter to create or Escape to cancel</p>
</div>
<div className="fixed inset-0 overflow-y-auto"> </ModalFrame>
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle as="h3" className="text-lg font-semibold text-foreground">
Create New Folder
</DialogTitle>
<div className="mt-4">
<Input
type="text"
value={folderName}
onChange={(e) => onFolderNameChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Enter folder name"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-1 focus:ring-accent"
autoFocus
/>
<p className="mt-2 text-xs text-muted">Press Enter to create or Escape to cancel</p>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
); );
} }

View file

@ -22,6 +22,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog'; import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog';
import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton'; import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton';
import { DocumentUploader, type UploadBatchState } from '@/components/documents/DocumentUploader'; import { DocumentUploader, type UploadBatchState } from '@/components/documents/DocumentUploader';
import { IconButton } from '@/components/ui';
import { DocumentDndProvider } from './dnd/DocumentDndProvider'; import { DocumentDndProvider } from './dnd/DocumentDndProvider';
import { import {
DocumentSelectionProvider, DocumentSelectionProvider,
@ -139,14 +140,14 @@ function SidebarUploadLoader({
const dashOffset = circumference - (progress / 100) * circumference; const dashOffset = circumference - (progress / 100) * circumference;
return ( return (
<div className="rounded-md border border-offbase bg-offbase/60 px-2 py-1.5"> <div className="rounded-md border border-line bg-surface-sunken px-2 py-1.5">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex items-center gap-1.5 text-[11px] leading-tight"> <div className="min-w-0 flex items-center gap-1.5 text-[11px] leading-tight">
<span className="font-medium text-foreground">{label}</span> <span className="font-medium text-foreground">{label}</span>
<span className="shrink-0 tabular-nums text-muted">{completedFiles}/{totalFiles}</span> <span className="shrink-0 tabular-nums text-soft">{completedFiles}/{totalFiles}</span>
</div> </div>
<div className="shrink-0 flex items-center gap-1 text-accent" aria-label={`Upload progress ${progress}%`}> <div className="shrink-0 flex items-center gap-1 text-accent" aria-label={`Upload progress ${progress}%`}>
<span className="text-[10px] tabular-nums text-muted">{progress}%</span> <span className="text-[10px] tabular-nums text-soft">{progress}%</span>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className={phase === 'converting' ? 'animate-spin' : ''}> <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className={phase === 'converting' ? 'animate-spin' : ''}>
<circle <circle
cx={size / 2} cx={size / 2}
@ -168,13 +169,13 @@ function SidebarUploadLoader({
strokeDasharray={`${circumference} ${circumference}`} strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={dashOffset} strokeDashoffset={dashOffset}
transform={`rotate(-90 ${size / 2} ${size / 2})`} transform={`rotate(-90 ${size / 2} ${size / 2})`}
style={{ transition: 'stroke-dashoffset 200ms ease-out' }} style={{ transition: 'stroke-dashoffset 200ms ease-standard' }}
/> />
</svg> </svg>
</div> </div>
</div> </div>
{currentFileName && ( {currentFileName && (
<p className="mt-0.5 truncate text-[10px] text-muted" title={currentFileName}> <p className="mt-0.5 truncate text-[10px] text-soft" title={currentFileName}>
{currentFileName} {currentFileName}
</p> </p>
)} )}
@ -182,6 +183,16 @@ function SidebarUploadLoader({
); );
} }
function DocumentListStateLoader() {
return (
<div
className="h-full w-full min-h-0 bg-surface-sunken animate-pulse"
aria-label="Loading documents"
aria-busy="true"
/>
);
}
function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const cachedState = cachedDocumentListState; const cachedState = cachedDocumentListState;
const [sortBy, setSortBy] = useState<SortBy>(cachedState?.sortBy ?? DEFAULT_STATE.sortBy); const [sortBy, setSortBy] = useState<SortBy>(cachedState?.sortBy ?? DEFAULT_STATE.sortBy);
@ -649,28 +660,32 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
}} }}
> >
{!isLoading && showHint && allDocuments.length > 1 && ( {!isLoading && showHint && allDocuments.length > 1 && (
<div className="px-3 pt-3 shrink-0 bg-background"> <div className="px-3 pt-3 shrink-0 bg-surface-sunken">
<div className="flex items-center justify-between bg-base border border-offbase rounded-md px-3 py-1 text-[12px]"> <div className="flex items-center justify-between bg-surface border border-line rounded-md px-3 py-1 text-[12px]">
<p className="text-foreground"> <p className="text-foreground">
Drag files onto each other to make folders. Drop into the sidebar to move. Drag files onto each other to make folders. Drop into the sidebar to move.
</p> </p>
<button <IconButton
type="button"
onClick={() => setShowHint(false)} onClick={() => setShowHint(false)}
className="h-6 w-6 inline-flex items-center justify-center text-muted hover:text-accent hover:bg-base hover:scale-[1.01] rounded transition-all duration-200 ease-out" size="xs"
className="h-6 w-6"
aria-label="Dismiss hint" aria-label="Dismiss hint"
> >
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </IconButton>
</div> </div>
</div> </div>
)} )}
{isLoading ? ( {isLoading ? (
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
<DocumentListSkeleton viewMode={fallbackViewMode} iconSize={iconSize} /> {isInitialized ? (
<DocumentListSkeleton viewMode={fallbackViewMode} iconSize={iconSize} />
) : (
<DocumentListStateLoader />
)}
</div> </div>
) : allDocuments.length === 0 ? ( ) : allDocuments.length === 0 ? (
<div className="flex-1 min-h-0 flex items-center justify-center p-6"> <div className="flex-1 min-h-0 flex items-center justify-center p-6">

View file

@ -15,11 +15,11 @@ function IconsSkeleton({ iconSize }: { iconSize: IconSize }) {
<div className="h-full min-h-0 overflow-y-auto p-3"> <div className="h-full min-h-0 overflow-y-auto p-3">
<div className="grid" style={iconsGridStyle(iconSize, ICON_SKELETON_ITEM_COUNT)}> <div className="grid" style={iconsGridStyle(iconSize, ICON_SKELETON_ITEM_COUNT)}>
{Array.from({ length: ICON_SKELETON_ITEM_COUNT }).map((_, index) => ( {Array.from({ length: ICON_SKELETON_ITEM_COUNT }).map((_, index) => (
<div key={index} className="overflow-hidden rounded-md border border-offbase bg-base"> <div key={index} className="overflow-hidden rounded-md border border-line bg-surface">
<div className="aspect-[3/4] w-full bg-offbase" /> <div className="aspect-[3/4] w-full bg-surface-sunken" />
<div className="flex items-center gap-2 px-2 py-2"> <div className="flex items-center gap-2 px-2 py-2">
<div className="h-3.5 w-3.5 rounded bg-offbase" /> <div className="h-3.5 w-3.5 rounded bg-surface-sunken" />
<div className="h-2.5 w-4/5 rounded bg-offbase" /> <div className="h-2.5 w-4/5 rounded bg-surface-sunken" />
</div> </div>
</div> </div>
))} ))}
@ -31,18 +31,18 @@ function IconsSkeleton({ iconSize }: { iconSize: IconSize }) {
function ListSkeleton() { function ListSkeleton() {
return ( return (
<div className="h-full min-h-0 overflow-y-auto"> <div className="h-full min-h-0 overflow-y-auto">
<div className="sticky top-0 z-10 bg-base border-b border-offbase grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px]"> <div className="sticky top-0 z-10 bg-surface border-b border-line-soft grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px]">
<div className="h-8 flex items-center px-2"> <div className="h-8 flex items-center px-2">
<div className="h-2.5 w-12 rounded bg-offbase" /> <div className="h-2.5 w-12 rounded bg-surface-sunken" />
</div> </div>
<div className="h-8 flex items-center px-2"> <div className="h-8 flex items-center px-2">
<div className="h-2.5 w-10 rounded bg-offbase" /> <div className="h-2.5 w-10 rounded bg-surface-sunken" />
</div> </div>
<div className="h-8 flex items-center justify-end px-2"> <div className="h-8 flex items-center justify-end px-2">
<div className="h-2.5 w-10 rounded bg-offbase" /> <div className="h-2.5 w-10 rounded bg-surface-sunken" />
</div> </div>
<div className="h-8 flex items-center px-2"> <div className="h-8 flex items-center px-2">
<div className="h-2.5 w-14 rounded bg-offbase" /> <div className="h-2.5 w-14 rounded bg-surface-sunken" />
</div> </div>
<div /> <div />
</div> </div>
@ -50,23 +50,23 @@ function ListSkeleton() {
{Array.from({ length: 10 }).map((_, index) => ( {Array.from({ length: 10 }).map((_, index) => (
<div <div
key={index} key={index}
className="grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px] items-center border-b border-offbase h-[35px]" className="grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px] items-center border-b border-line-soft h-[35px]"
> >
<div className="px-2 flex items-center gap-2"> <div className="px-2 flex items-center gap-2">
<div className="h-3.5 w-3.5 rounded bg-offbase" /> <div className="h-3.5 w-3.5 rounded bg-surface-sunken" />
<div className="h-2.5 w-2/3 rounded bg-offbase" /> <div className="h-2.5 w-2/3 rounded bg-surface-sunken" />
</div> </div>
<div className="px-2"> <div className="px-2">
<div className="h-2.5 w-8 rounded bg-offbase" /> <div className="h-2.5 w-8 rounded bg-surface-sunken" />
</div> </div>
<div className="px-2 flex justify-end"> <div className="px-2 flex justify-end">
<div className="h-2.5 w-12 rounded bg-offbase" /> <div className="h-2.5 w-12 rounded bg-surface-sunken" />
</div> </div>
<div className="px-2"> <div className="px-2">
<div className="h-2.5 w-16 rounded bg-offbase" /> <div className="h-2.5 w-16 rounded bg-surface-sunken" />
</div> </div>
<div className="px-2 flex justify-center"> <div className="px-2 flex justify-center">
<div className="h-4 w-4 rounded bg-offbase" /> <div className="h-4 w-4 rounded bg-surface-sunken" />
</div> </div>
</div> </div>
))} ))}
@ -78,24 +78,24 @@ function ListSkeleton() {
function GallerySkeleton() { function GallerySkeleton() {
return ( return (
<div className="h-full min-h-0 flex flex-col"> <div className="h-full min-h-0 flex flex-col">
<div className="flex-1 min-h-0 flex items-center justify-center p-6 bg-background"> <div className="flex-1 min-h-0 flex items-center justify-center p-6 bg-surface-sunken">
<div className="flex flex-col items-center gap-3 max-w-[420px]"> <div className="flex flex-col items-center gap-3 max-w-[420px]">
<div className="w-[260px] sm:w-[320px] aspect-[3/4] rounded-lg border border-offbase bg-offbase" /> <div className="w-[260px] sm:w-[320px] aspect-[3/4] rounded-lg border border-line bg-surface-sunken" />
<div className="h-3 w-40 rounded bg-offbase" /> <div className="h-3 w-40 rounded bg-surface-sunken" />
<div className="h-2.5 w-28 rounded bg-offbase" /> <div className="h-2.5 w-28 rounded bg-surface-sunken" />
<div className="flex gap-2"> <div className="flex gap-2">
<div className="h-8 w-20 rounded-md bg-offbase" /> <div className="h-8 w-20 rounded-md bg-surface-sunken" />
<div className="h-8 w-20 rounded-md bg-offbase" /> <div className="h-8 w-20 rounded-md bg-surface-sunken" />
</div> </div>
</div> </div>
</div> </div>
<div className="shrink-0 border-t border-offbase bg-base"> <div className="shrink-0 border-t border-line-soft bg-surface">
<div className="flex gap-2 overflow-x-auto p-2"> <div className="flex gap-2 overflow-x-auto p-2">
{Array.from({ length: 10 }).map((_, index) => ( {Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="shrink-0 w-[88px] rounded-md overflow-hidden border border-offbase bg-base"> <div key={index} className="shrink-0 w-[88px] rounded-md overflow-hidden border border-line bg-surface">
<div className="aspect-[3/4] bg-offbase" /> <div className="aspect-[3/4] bg-surface-sunken" />
<div className="p-1.5"> <div className="p-1.5">
<div className="h-2.5 w-5/6 rounded bg-offbase" /> <div className="h-2.5 w-5/6 rounded bg-surface-sunken" />
</div> </div>
</div> </div>
))} ))}

View file

@ -243,7 +243,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="relative w-full aspect-[3/4] overflow-hidden rounded-t-md bg-base" className="relative w-full aspect-[3/4] overflow-hidden rounded-t-md bg-surface"
> >
{imagePreview ? ( {imagePreview ? (
<> <>
@ -261,7 +261,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
ref={imageRef} ref={imageRef}
src={imagePreview} src={imagePreview}
alt={`${doc.name} preview`} alt={`${doc.name} preview`}
className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-150 ${isImageReady ? 'opacity-100' : 'opacity-0'}`} className={`absolute inset-0 h-full w-full object-cover transition-opacity duration-fast ${isImageReady ? 'opacity-100' : 'opacity-0'}`}
draggable={false} draggable={false}
loading="lazy" loading="lazy"
onLoad={() => { onLoad={() => {
@ -285,16 +285,16 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
</> </>
) : textPreview ? ( ) : textPreview ? (
<> <>
<div className="absolute inset-0 bg-gradient-to-br from-slate-50 to-slate-200" /> <div className="absolute inset-0 bg-gradient-to-br from-surface-solid to-surface-sunken" />
<div className="absolute inset-0 opacity-[0.06] bg-[radial-gradient(circle_at_1px_1px,rgba(0,0,0,1)_1px,transparent_0)] [background-size:12px_12px]" /> <div className="absolute inset-0 opacity-[0.06] bg-[radial-gradient(circle_at_1px_1px,rgba(0,0,0,1)_1px,transparent_0)] [background-size:12px_12px]" />
<div className="relative z-10 h-full w-full p-2 flex flex-col"> <div className="relative z-10 h-full w-full p-2 flex flex-col">
<div className="mt-auto rounded-md bg-white/70 backdrop-blur-[1px] shadow-sm ring-1 ring-black/5 p-2.5 max-h-[70%] overflow-hidden"> <div className="mt-auto rounded-md bg-surface-solid backdrop-blur-[1px] shadow-elev-1 ring-1 ring-line-soft p-2.5 max-h-[70%] overflow-hidden">
{isTxtFile ? ( {isTxtFile ? (
<pre className="text-[10px] sm:text-[11px] leading-snug text-slate-900 whitespace-pre-wrap font-mono"> <pre className="text-[10px] sm:text-[11px] leading-snug text-foreground whitespace-pre-wrap font-mono">
{textPreview} {textPreview}
</pre> </pre>
) : ( ) : (
<div className="text-[10px] sm:text-[11px] leading-snug text-slate-900 break-words [overflow-wrap:anywhere]"> <div className="text-[10px] sm:text-[11px] leading-snug text-foreground break-words [overflow-wrap:anywhere]">
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
@ -311,11 +311,11 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
a: ({ children }) => <span>{children}</span>, a: ({ children }) => <span>{children}</span>,
img: () => null, img: () => null,
blockquote: (props) => ( blockquote: (props) => (
<blockquote className="m-0 pl-2 border-l-2 border-slate-300 text-slate-700" {...props} /> <blockquote className="m-0 pl-2 border-l-2 border-line text-soft" {...props} />
), ),
code: (props) => ( code: (props) => (
<code <code
className="font-mono text-[10px] bg-slate-900/5 rounded px-1 whitespace-pre-wrap break-words [overflow-wrap:anywhere]" className="font-mono text-[10px] bg-surface-sunken rounded px-1 whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
{...props} {...props}
/> />
), ),

View file

@ -2,10 +2,10 @@
import Link from 'next/link'; import Link from 'next/link';
import { useDrag, useDrop, type DragSourceMonitor } from 'react-dnd'; import { useDrag, useDrop, type DragSourceMonitor } from 'react-dnd';
import { Button } from '@headlessui/react';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import type { DocumentListDocument, IconSize } from '@/types/documents'; import type { DocumentListDocument, IconSize } from '@/types/documents';
import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { DocumentPreview } from '@/components/doclist/DocumentPreview';
import { IconButton } from '@/components/ui';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
@ -138,11 +138,11 @@ export function DocumentTile({
data-doc-tile data-doc-tile
aria-selected={isSelected} aria-selected={isSelected}
className={ className={
'group relative flex flex-col rounded-md overflow-hidden border transition-all duration-200 ease-out hover:scale-[1.01] ' + 'group relative flex flex-col rounded-md overflow-hidden border transition duration-base ease-standard ' +
(isSelected (isSelected
? 'border-accent bg-offbase' ? 'border-accent-line bg-surface-sunken'
: 'border-offbase bg-base hover:bg-offbase hover:border-accent') + : 'border-line bg-surface hover:bg-accent-wash hover:border-accent-line') +
(isDropTarget ? ' ring-1 ring-accent' : '') + (isDropTarget ? ' ring-1 ring-accent-line' : '') +
(isDragging ? ' opacity-50' : '') (isDragging ? ' opacity-50' : '')
} }
> >
@ -166,11 +166,11 @@ export function DocumentTile({
> >
<span className="flex-shrink-0 flex items-center"> <span className="flex-shrink-0 flex items-center">
{doc.type === 'pdf' ? ( {doc.type === 'pdf' ? (
<PDFIcon className={`${FILE_ICON_CLASSES[iconSize]} text-red-500`} /> <PDFIcon className={`${FILE_ICON_CLASSES[iconSize]} text-danger`} />
) : doc.type === 'epub' ? ( ) : doc.type === 'epub' ? (
<EPUBIcon className={`${FILE_ICON_CLASSES[iconSize]} text-blue-500`} /> <EPUBIcon className={`${FILE_ICON_CLASSES[iconSize]} text-accent`} />
) : ( ) : (
<FileIcon className={`${FILE_ICON_CLASSES[iconSize]} text-muted`} /> <FileIcon className={`${FILE_ICON_CLASSES[iconSize]} text-soft`} />
)} )}
</span> </span>
<span <span
@ -185,9 +185,10 @@ export function DocumentTile({
</span> </span>
</Link> </Link>
{showDeleteButton && ( {showDeleteButton && (
<Button <IconButton
onClick={() => onDelete(doc)} onClick={() => onDelete(doc)}
className={`inline-flex items-center justify-center text-muted hover:text-accent hover:bg-base focus:outline-none transition-colors duration-200 ${TRASH_BTN_CLASSES[iconSize]}`} size="xs"
className={TRASH_BTN_CLASSES[iconSize]}
aria-label={`Delete ${doc.name}`} aria-label={`Delete ${doc.name}`}
> >
<svg className={TRASH_ICON_CLASSES[iconSize]} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className={TRASH_ICON_CLASSES[iconSize]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -198,7 +199,7 @@ export function DocumentTile({
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/> />
</svg> </svg>
</Button> </IconButton>
)} )}
</div> </div>
</div> </div>

View file

@ -1,13 +1,12 @@
'use client'; 'use client';
import Link from 'next/link';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useDrag, useDrop } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd';
import type { DocumentListDocument } from '@/types/documents'; import type { DocumentListDocument } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { DocumentPreview } from '@/components/doclist/DocumentPreview';
import { formatDocumentSize } from '@/components/doclist/formatSize'; import { formatDocumentSize } from '@/components/doclist/formatSize';
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { Button, ButtonLink } from '@/components/ui';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
@ -38,9 +37,9 @@ function formatParseStatus(status: DocumentListDocument['parseStatus']): string
} }
function KindIcon({ doc, className }: { doc: DocumentListDocument; className?: string }) { function KindIcon({ doc, className }: { doc: DocumentListDocument; className?: string }) {
if (doc.type === 'pdf') return <PDFIcon className={className ?? 'w-4 h-4 text-red-500'} />; if (doc.type === 'pdf') return <PDFIcon className={className ?? 'w-4 h-4 text-danger'} />;
if (doc.type === 'epub') return <EPUBIcon className={className ?? 'w-4 h-4 text-blue-500'} />; if (doc.type === 'epub') return <EPUBIcon className={className ?? 'w-4 h-4 text-accent'} />;
return <FileIcon className={className ?? 'w-4 h-4 text-muted'} />; return <FileIcon className={className ?? 'w-4 h-4 text-soft'} />;
} }
function GalleryThumb({ function GalleryThumb({
@ -92,34 +91,34 @@ function GalleryThumb({
onClick={onClick} onClick={onClick}
aria-current={active ? 'true' : undefined} aria-current={active ? 'true' : undefined}
className={ className={
'group relative w-[98px] sm:w-[110px] shrink-0 cursor-pointer rounded-lg overflow-hidden border bg-base snap-start transition-all duration-200 ease-out ' + 'group relative w-[98px] sm:w-[110px] shrink-0 cursor-pointer rounded-lg overflow-hidden border bg-surface snap-start transition duration-base ease-standard ' +
(active (active
? 'border-accent shadow-[0_10px_24px_-18px_rgba(0,0,0,0.85)] -translate-y-0.5' ? 'border-accent-line shadow-elev-2 -translate-y-px'
: 'border-offbase hover:border-accent hover:-translate-y-0.5') + : 'border-line hover:border-accent-line hover:-translate-y-px hover:shadow-elev-2') +
(isOver && canDrop ? ' border-accent' : '') + (isOver && canDrop ? ' border-accent-line' : '') +
(isDragging ? ' opacity-50' : '') (isDragging ? ' opacity-50' : '')
} }
title={doc.name} title={doc.name}
> >
<div className="aspect-[3/4] bg-base"> <div className="aspect-[3/4] bg-surface">
<DocumentPreview doc={doc} /> <DocumentPreview doc={doc} />
</div> </div>
<div <div
className={ className={
'px-2 py-1.5 flex items-center gap-1.5 border-t transition-colors duration-200 ' + 'px-2 py-1.5 flex items-center gap-1.5 border-t transition-colors duration-base ' +
(active ? 'bg-offbase border-accent' : 'bg-base border-offbase') (active ? 'bg-surface-sunken border-accent-line' : 'bg-surface border-line')
} }
> >
<KindIcon <KindIcon
doc={doc} doc={doc}
className={ className={
'w-3 h-3 shrink-0 transition-colors duration-200 ' + 'w-3 h-3 shrink-0 transition-colors duration-base ' +
(active ? 'text-accent' : 'text-muted') (active ? 'text-accent' : 'text-soft')
} }
/> />
<span <span
className={ className={
'truncate text-[10px] leading-none transition-colors duration-200 ' + 'truncate text-[10px] leading-none transition-colors duration-base ' +
(active ? 'text-accent font-medium' : 'text-foreground') (active ? 'text-accent font-medium' : 'text-foreground')
} }
> >
@ -181,51 +180,48 @@ export function GalleryView({
return ( return (
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 min-h-0 overflow-y-auto bg-background"> <div className="flex-1 min-h-0 overflow-y-auto bg-surface-sunken">
<div className="min-h-full flex items-center justify-center p-3 sm:p-6"> <div className="min-h-full flex items-center justify-center p-3 sm:p-6">
{activeDoc ? ( {activeDoc ? (
<div className="w-full max-w-[920px] flex flex-col md:flex-row items-center md:items-start justify-center gap-4 md:gap-6"> <div className="w-full max-w-[920px] flex flex-col md:flex-row items-center md:items-start justify-center gap-4 md:gap-6">
<div className="flex flex-col items-center gap-3 w-[180px] sm:w-[260px] md:w-[320px] shrink-0"> <div className="flex flex-col items-center gap-3 w-[180px] sm:w-[260px] md:w-[320px] shrink-0">
<div className="w-full aspect-[3/4] rounded-lg overflow-hidden border border-offbase shadow-lg"> <div className="w-full aspect-[3/4] rounded-lg overflow-hidden border border-line shadow-elev-2">
<DocumentPreview doc={activeDoc} /> <DocumentPreview doc={activeDoc} />
</div> </div>
<div className="text-center"> <div className="text-center">
<h2 className="text-[14px] font-semibold text-foreground truncate max-w-[320px]"> <h2 className="text-[14px] font-semibold text-foreground truncate max-w-[320px]">
{activeDoc.name} {activeDoc.name}
</h2> </h2>
<p className="text-[11px] text-muted"> <p className="text-[11px] text-soft">
{activeDoc.type.toUpperCase()} {formatDocumentSize(activeDoc.size)} {activeDoc.type.toUpperCase()} {formatDocumentSize(activeDoc.size)}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Link <ButtonLink href={openHref || '/app'} prefetch={false} variant="primary" size="sm">
href={openHref || '/app'}
prefetch={false}
className={buttonClass({ variant: 'primary', size: 'sm' })}
>
Open Open
</Link> </ButtonLink>
<button <Button
type="button" type="button"
onClick={() => onDeleteDoc(activeDoc)} onClick={() => onDeleteDoc(activeDoc)}
className={buttonClass({ variant: 'secondary', size: 'sm' })} variant="secondary"
size="sm"
> >
Delete Delete
</button> </Button>
</div> </div>
</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-offbase bg-base px-3 py-2 text-[11px] md:self-center"> <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">
<dt className="text-muted">Type</dt> <dt className="text-soft">Type</dt>
<dd className="text-foreground text-right uppercase tracking-wide">{activeDoc.type}</dd> <dd className="text-foreground text-right uppercase tracking-wide">{activeDoc.type}</dd>
<dt className="text-muted">Size</dt> <dt className="text-soft">Size</dt>
<dd className="text-foreground text-right tabular-nums">{formatDocumentSize(activeDoc.size)}</dd> <dd className="text-foreground text-right tabular-nums">{formatDocumentSize(activeDoc.size)}</dd>
<dt className="text-muted">Last opened</dt> <dt className="text-soft">Last opened</dt>
<dd className="text-foreground text-right">{formatDateTime(activeDoc.recentlyOpenedAt)}</dd> <dd className="text-foreground text-right">{formatDateTime(activeDoc.recentlyOpenedAt)}</dd>
<dt className="text-muted">Last modified</dt> <dt className="text-soft">Last modified</dt>
<dd className="text-foreground text-right">{formatDateTime(activeDoc.lastModified)}</dd> <dd className="text-foreground text-right">{formatDateTime(activeDoc.lastModified)}</dd>
{activeDoc.folderId && ( {activeDoc.folderId && (
<> <>
<dt className="text-muted">Folder</dt> <dt className="text-soft">Folder</dt>
<dd className="text-foreground text-right truncate" title={folderNameById?.[activeDoc.folderId] ?? activeDoc.folderId}> <dd className="text-foreground text-right truncate" title={folderNameById?.[activeDoc.folderId] ?? activeDoc.folderId}>
{folderNameById?.[activeDoc.folderId] ?? activeDoc.folderId} {folderNameById?.[activeDoc.folderId] ?? activeDoc.folderId}
</dd> </dd>
@ -233,19 +229,19 @@ export function GalleryView({
)} )}
{activeDoc.type === 'pdf' && ( {activeDoc.type === 'pdf' && (
<> <>
<dt className="text-muted">Parse status</dt> <dt className="text-soft">Parse status</dt>
<dd className="text-foreground text-right">{formatParseStatus(activeDoc.parseStatus)}</dd> <dd className="text-foreground text-right">{formatParseStatus(activeDoc.parseStatus)}</dd>
</> </>
)} )}
</dl> </dl>
</div> </div>
) : ( ) : (
<p className="text-[12px] text-muted">No documents to show</p> <p className="text-[12px] text-soft">No documents to show</p>
)} )}
</div> </div>
</div> </div>
<div className="shrink-0 border-t border-offbase bg-gradient-to-b from-base to-offbase/30"> <div className="shrink-0 border-t border-line-soft bg-gradient-to-b from-surface to-surface-sunken">
<div <div
ref={railRef} ref={railRef}
className="flex gap-2.5 overflow-x-auto pl-4 pr-3 pt-2.5 pb-1.5 snap-x snap-proximity scroll-pl-4 sm:scroll-pl-5 scroll-pr-3 sm:scroll-pr-4" className="flex gap-2.5 overflow-x-auto pl-4 pr-3 pt-2.5 pb-1.5 snap-x snap-proximity scroll-pl-4 sm:scroll-pl-5 scroll-pr-3 sm:scroll-pr-4"

View file

@ -3,7 +3,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useDrag, useDrop } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd';
import { Button } from '@headlessui/react';
import type { import type {
DocumentListDocument, DocumentListDocument,
SortBy, SortBy,
@ -11,6 +10,7 @@ import type {
} from '@/types/documents'; } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { formatDocumentSize } from '@/components/doclist/formatSize'; import { formatDocumentSize } from '@/components/doclist/formatSize';
import { IconButton } from '@/components/ui';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
@ -29,9 +29,9 @@ function formatDate(ms: number): string {
} }
function KindIcon({ doc }: { doc: DocumentListDocument }) { function KindIcon({ doc }: { doc: DocumentListDocument }) {
if (doc.type === 'pdf') return <PDFIcon className="w-4 h-4 shrink-0 text-red-500" />; if (doc.type === 'pdf') return <PDFIcon className="w-4 h-4 shrink-0 text-danger" />;
if (doc.type === 'epub') return <EPUBIcon className="w-4 h-4 shrink-0 text-blue-500" />; if (doc.type === 'epub') return <EPUBIcon className="w-4 h-4 shrink-0 text-accent" />;
return <FileIcon className="w-4 h-4 shrink-0 text-muted" />; return <FileIcon className="w-4 h-4 shrink-0 text-soft" />;
} }
function HeaderCell({ function HeaderCell({
@ -62,8 +62,8 @@ function HeaderCell({
onSortChange(field, nextDir); onSortChange(field, nextDir);
}} }}
className={ className={
'flex items-center gap-1 px-2 py-1.5 text-[10px] uppercase tracking-wide font-semibold transition-colors duration-200 ease-out hover:text-accent ' + 'flex items-center gap-1 px-2 py-1.5 text-[10px] uppercase tracking-wide font-semibold transition-colors duration-base ease-standard hover:text-accent ' +
(active ? 'text-accent' : 'text-muted') + (active ? 'text-accent' : 'text-soft') +
(align === 'right' ? ' justify-end' : '') + (align === 'right' ? ' justify-end' : '') +
' ' + ' ' +
(className ?? '') (className ?? '')
@ -131,11 +131,11 @@ function DocRow({
data-doc-tile data-doc-tile
aria-selected={isSelected} aria-selected={isSelected}
className={ className={
'grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px] items-center text-[12px] border-b border-offbase transition-colors duration-200 ease-out ' + 'grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px] items-center text-[12px] border-b border-line-soft transition-colors duration-base ease-standard ' +
(isSelected (isSelected
? 'bg-offbase text-accent' ? 'bg-surface-sunken text-accent'
: 'text-foreground hover:bg-offbase') + : 'text-foreground hover:bg-accent-wash') +
(isTarget ? ' ring-1 ring-accent ring-inset' : '') + (isTarget ? ' ring-1 ring-accent-line ring-inset' : '') +
(isDragging ? ' opacity-50' : '') (isDragging ? ' opacity-50' : '')
} }
> >
@ -149,19 +149,19 @@ function DocRow({
<KindIcon doc={doc} /> <KindIcon doc={doc} />
<span className="truncate">{doc.name}</span> <span className="truncate">{doc.name}</span>
</Link> </Link>
<span className="px-2 text-[11px] text-muted uppercase tracking-wide">{doc.type}</span> <span className="px-2 text-[11px] text-soft uppercase tracking-wide">{doc.type}</span>
<span className="px-2 text-[11px] text-muted text-right tabular-nums"> <span className="px-2 text-[11px] text-soft text-right tabular-nums">
{formatDocumentSize(doc.size)} {formatDocumentSize(doc.size)}
</span> </span>
<span className="px-2 text-[11px] text-muted tabular-nums"> <span className="px-2 text-[11px] text-soft tabular-nums">
{formatDate(doc.lastModified)} {formatDate(doc.lastModified)}
</span> </span>
<Button <IconButton
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
onDeleteDoc(doc); onDeleteDoc(doc);
}} }}
className="h-7 w-7 flex items-center justify-center text-muted hover:text-accent hover:bg-offbase hover:scale-[1.02] rounded transition-all duration-200 ease-out" size="sm"
aria-label={`Delete ${doc.name}`} aria-label={`Delete ${doc.name}`}
> >
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -172,7 +172,7 @@ function DocRow({
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/> />
</svg> </svg>
</Button> </IconButton>
</div> </div>
); );
} }
@ -198,7 +198,7 @@ export function ListView({
return ( return (
<div onClick={handleBackgroundClick} className="flex-1 min-h-0 overflow-y-auto"> <div onClick={handleBackgroundClick} className="flex-1 min-h-0 overflow-y-auto">
<div className="sticky top-0 z-10 bg-base border-b border-offbase grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px]"> <div className="sticky top-0 z-10 bg-surface border-b border-line-soft grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px]">
<HeaderCell label="Name" field="name" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} /> <HeaderCell label="Name" field="name" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} />
<HeaderCell label="Kind" field="type" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} /> <HeaderCell label="Kind" field="type" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} />
<HeaderCell label="Size" field="size" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} align="right" /> <HeaderCell label="Size" field="size" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} align="right" />

View file

@ -1,12 +1,13 @@
'use client'; '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 { Fragment, useRef, type CSSProperties, type ReactNode } from 'react';
import { useDrop } from 'react-dnd'; import { useDrop } from 'react-dnd';
import type { Folder, SidebarFilter } from '@/types/documents'; import type { Folder, SidebarFilter } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons'; import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons'; import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
import { IconButton, MenuActionItem, MenuItemsSurface, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui';
interface FinderSidebarProps { interface FinderSidebarProps {
filter: SidebarFilter; filter: SidebarFilter;
@ -30,60 +31,6 @@ interface FinderSidebarProps {
const MIN_WIDTH = 168; const MIN_WIDTH = 168;
const MAX_WIDTH = 320; const MAX_WIDTH = 320;
interface SidebarRowProps {
active: boolean;
onClick: () => void;
icon: ReactNode;
label: string;
count?: number;
countClassName?: string;
trailing?: ReactNode;
isDropTarget?: boolean;
}
function SidebarRow({
active,
onClick,
icon,
label,
count,
countClassName,
trailing,
isDropTarget,
}: SidebarRowProps) {
return (
<button
type="button"
onClick={onClick}
className={
'group w-full flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border transform transition-all duration-200 ease-out text-left hover:scale-[1.01] ' +
(active
? 'border-accent bg-offbase text-accent'
: 'border-transparent bg-transparent text-foreground hover:border-accent hover:text-accent') +
(isDropTarget ? ' ring-1 ring-accent' : '')
}
>
<span
className={
'w-4 h-4 shrink-0 flex items-center justify-center transition-colors duration-200 ' +
(active ? 'text-accent' : 'text-muted group-hover:text-accent')
}
>
{icon}
</span>
<span className="truncate flex-1">{label}</span>
{typeof count === 'number' && count > 0 && (
<span
className={`text-[10px] text-muted tabular-nums transition-transform duration-200 ease-out ${countClassName ?? ''}`}
>
{count}
</span>
)}
{trailing}
</button>
);
}
function FolderRow({ function FolderRow({
folder, folder,
active, active,
@ -122,7 +69,8 @@ function FolderRow({
ref={dropRef as unknown as React.RefObject<HTMLDivElement>} ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
className="group/folder relative" className="group/folder relative"
> >
<SidebarRow <SidebarNavItem
compact
active={active} active={active}
onClick={onClick} onClick={onClick}
icon={<FolderIcon className="w-3.5 h-3.5" />} icon={<FolderIcon className="w-3.5 h-3.5" />}
@ -131,42 +79,20 @@ function FolderRow({
countClassName="group-hover/folder:-translate-x-6 group-focus-within/folder:-translate-x-6" countClassName="group-hover/folder:-translate-x-6 group-focus-within/folder:-translate-x-6"
isDropTarget={isDropTarget} isDropTarget={isDropTarget}
/> />
<button <IconButton
type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDelete(); onDelete();
}} }}
className="absolute right-1 top-1/2 -translate-y-1/2 h-5 w-5 inline-flex items-center justify-center rounded text-muted opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100 hover:text-accent hover:bg-offbase transition" size="xs"
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100"
aria-label={`Delete ${folder.name}`} aria-label={`Delete ${folder.name}`}
title={`Delete ${folder.name}`} title={`Delete ${folder.name}`}
> >
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </IconButton>
</div>
);
}
function SectionHeader({
children,
isFirst,
rightSlot,
}: {
children: ReactNode;
isFirst?: boolean;
rightSlot?: ReactNode;
}) {
return (
<div
className={
'px-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-muted font-semibold leading-none flex items-center justify-between ' +
(isFirst ? 'pt-1.5' : 'pt-3')
}
>
<span>{children}</span>
{rightSlot && <span className="inline-flex items-center leading-none shrink-0">{rightSlot}</span>}
</div> </div>
); );
} }
@ -204,19 +130,20 @@ export function FinderSidebar({
}; };
return ( return (
<aside <SidebarShell
style={{ '--sidebar-width': `${width}px` } as CSSProperties} style={{ '--sidebar-width': `${width}px` } as CSSProperties}
className="relative h-full w-full md:[width:var(--sidebar-width)] bg-base border-r border-offbase shrink-0 flex flex-col" className="relative h-full w-full md:[width:var(--sidebar-width)] rounded-none border-y-0 border-l-0 border-r border-line-soft shadow-none shrink-0 flex flex-col"
> >
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
<div className="p-2 flex flex-col gap-0.5"> <SidebarNav className="p-2">
{topSlot && ( {topSlot && (
<div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}> <div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
{topSlot} {topSlot}
</div> </div>
)} )}
<SectionHeader isFirst={!!topSlot}>Library</SectionHeader> <SidebarNavGroup isFirst={!!topSlot}>Library</SidebarNavGroup>
<SidebarRow <SidebarNavItem
compact
active={filter === 'all'} active={filter === 'all'}
onClick={() => { onClick={() => {
onFilterChange('all'); onFilterChange('all');
@ -226,7 +153,8 @@ export function FinderSidebar({
label="All Documents" label="All Documents"
count={counts.all} count={counts.all}
/> />
<SidebarRow <SidebarNavItem
compact
active={filter === 'recents'} active={filter === 'recents'}
onClick={() => { onClick={() => {
onFilterChange('recents'); onFilterChange('recents');
@ -236,8 +164,9 @@ export function FinderSidebar({
label="Recently Opened" label="Recently Opened"
/> />
<SectionHeader>Kinds</SectionHeader> <SidebarNavGroup>Kinds</SidebarNavGroup>
<SidebarRow <SidebarNavItem
compact
active={filter === 'pdf'} active={filter === 'pdf'}
onClick={() => { onClick={() => {
onFilterChange('pdf'); onFilterChange('pdf');
@ -247,7 +176,8 @@ export function FinderSidebar({
label="PDF" label="PDF"
count={counts.pdf} count={counts.pdf}
/> />
<SidebarRow <SidebarNavItem
compact
active={filter === 'epub'} active={filter === 'epub'}
onClick={() => { onClick={() => {
onFilterChange('epub'); onFilterChange('epub');
@ -257,7 +187,8 @@ export function FinderSidebar({
label="EPUB" label="EPUB"
count={counts.epub} count={counts.epub}
/> />
<SidebarRow <SidebarNavItem
compact
active={filter === 'html'} active={filter === 'html'}
onClick={() => { onClick={() => {
onFilterChange('html'); onFilterChange('html');
@ -268,11 +199,13 @@ export function FinderSidebar({
count={counts.html} count={counts.html}
/> />
<SectionHeader <SidebarNavGroup
rightSlot={( action={(
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal"> <Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
<MenuButton <MenuButton
className="inline-flex items-center justify-center h-3.5 w-5 rounded-sm text-muted hover:text-accent transition-colors duration-200 ease-out focus:outline-none" as={IconButton}
size="xs"
className="h-3.5 w-5"
title="Folder actions" title="Folder actions"
aria-label="Folder actions" aria-label="Folder actions"
> >
@ -280,59 +213,47 @@ export function FinderSidebar({
</MenuButton> </MenuButton>
<Transition <Transition
as={Fragment} as={Fragment}
enter="transition ease-out duration-100" enter="transition ease-standard duration-fast"
enterFrom="transform opacity-0 scale-95" enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100" enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75" leave="transition ease-standard duration-fast"
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<MenuItems <MenuItemsSurface
anchor="bottom start" anchor="bottom start"
className="z-50 mt-2 min-w-[180px] rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none p-1 normal-case tracking-normal font-normal" className="z-50 mt-2 min-w-[180px] focus:outline-none normal-case tracking-normal font-normal"
> >
<MenuItem> <MenuActionItem
{({ active }) => ( onClick={() => {
<button onNewFolder();
type="button" onRowAction?.();
onClick={() => { }}
onNewFolder(); >
onRowAction?.(); <FolderPlusIcon className="h-4 w-4" />
}} New Folder
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`} </MenuActionItem>
> <MenuActionItem
<FolderPlusIcon className="h-4 w-4" /> disabled={folders.length === 0}
New Folder onClick={() => {
</button> onClearFolders();
)} onRowAction?.();
</MenuItem> }}
<MenuItem disabled={folders.length === 0}> >
{({ active, disabled }) => ( <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
type="button" </svg>
onClick={() => { Remove All Folders
onClearFolders(); </MenuActionItem>
onRowAction?.(); </MenuItemsSurface>
}}
disabled={disabled}
className={`${disabled ? 'text-muted/60 cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<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>
</Transition> </Transition>
</Menu> </Menu>
)} )}
> >
Folders Folders
</SectionHeader> </SidebarNavGroup>
{folders.length === 0 ? ( {folders.length === 0 ? (
<p className="px-2 py-1 text-[11px] text-muted">No folders yet</p> <p className="px-2 py-1 text-[11px] text-soft">No folders yet</p>
) : ( ) : (
folders.map((folder) => ( folders.map((folder) => (
<FolderRow <FolderRow
@ -351,11 +272,11 @@ export function FinderSidebar({
/> />
)) ))
)} )}
</div> </SidebarNav>
</div> </div>
{bottomSlot && ( {bottomSlot && (
<div <div
className="shrink-0 border-t border-offbase px-2 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] md:pb-2" className="shrink-0 border-t border-line-soft px-2 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] md:pb-2"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{bottomSlot} {bottomSlot}
@ -370,8 +291,8 @@ export function FinderSidebar({
onPointerMove={onResizeMove} onPointerMove={onResizeMove}
onPointerUp={onResizeEnd} onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd} onPointerCancel={onResizeEnd}
className="hidden md:block absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-offbase active:bg-accent transition-colors duration-200 ease-out" className="hidden md:block absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-accent-wash active:bg-accent transition-colors duration-base ease-standard"
/> />
</aside> </SidebarShell>
); );
} }

View file

@ -19,14 +19,14 @@ export function FinderStatusBar({
<div <div
role="status" role="status"
aria-live="polite" aria-live="polite"
className="min-h-6 px-3 pb-[env(safe-area-inset-bottom)] flex items-center justify-between gap-3 text-[11px] text-muted bg-base border-t border-offbase select-none" className="min-h-6 px-3 pb-[env(safe-area-inset-bottom)] flex items-center justify-between gap-3 text-[11px] text-soft bg-surface border-t border-line-soft select-none"
> >
<span className="truncate">{summary}</span> <span className="truncate">{summary}</span>
<span className="shrink-0"> <span className="shrink-0">
{selectedCount > 0 {selectedCount > 0
? `${selectedCount} of ${itemCount} selected` ? `${selectedCount} of ${itemCount} selected`
: `${itemCount} item${itemCount === 1 ? '' : 's'}`} : `${itemCount} item${itemCount === 1 ? '' : 's'}`}
<span className="mx-1.5 text-muted"></span> <span className="mx-1.5 text-soft"></span>
{formatDocumentSize(totalSize)} {formatDocumentSize(totalSize)}
</span> </span>
</div> </div>

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'; import { Listbox } from '@headlessui/react';
import type { IconSize, SortBy, SortDirection, ViewMode } from '@/types/documents'; import type { IconSize, SortBy, SortDirection, ViewMode } from '@/types/documents';
import { import {
IconsViewIcon, IconsViewIcon,
@ -10,6 +10,7 @@ import {
HamburgerIcon, HamburgerIcon,
} from './finderIcons'; } from './finderIcons';
import { ChevronUpDownIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon } from '@/components/icons/Icons';
import { SearchField, SharedListboxButton, SharedListboxOption, SharedListboxOptions, Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment } from '@/components/ui';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
interface FinderToolbarProps { interface FinderToolbarProps {
@ -52,22 +53,6 @@ const ICON_SIZES: Array<{ value: IconSize; label: string }> = [
{ value: 'xl', label: 'XL' }, { 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-base text-xs transition-all duration-200 ease-out hover:scale-[1.01]';
const TOOLBAR_BTN_INACTIVE =
'border-offbase text-foreground hover:text-accent hover:border-accent hover:bg-offbase';
const TOOLBAR_BTN_ACTIVE = 'border-accent bg-offbase 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-offbase bg-base p-0.5 gap-0.5 shrink-0';
const PILL_SEGMENT =
'inline-flex items-center justify-center rounded-[5px] text-xs transition-colors duration-200 ease-out';
const PILL_SEGMENT_INACTIVE = 'text-muted hover:bg-offbase hover:text-accent';
const PILL_SEGMENT_ACTIVE = 'bg-offbase text-accent';
export function FinderToolbar({ export function FinderToolbar({
viewMode, viewMode,
onViewModeChange, onViewModeChange,
@ -89,26 +74,25 @@ export function FinderToolbar({
const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc; const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc;
return ( return (
<div className="sticky top-0 z-40 w-full border-b border-offbase bg-base"> <Toolbar>
<div className="px-2 sm:px-3 py-1 min-h-10 flex items-center gap-1.5 sm:gap-2">
{leftSlot && ( {leftSlot && (
<div className="shrink-0 flex items-center gap-2 pr-1 sm:pr-2 sm:border-r sm:border-offbase"> <div className="shrink-0 flex items-center gap-2 pr-1 sm:pr-2 sm:border-r sm:border-line">
{leftSlot} {leftSlot}
</div> </div>
)} )}
<button <ToolbarButton
type="button"
onClick={onToggleSidebar} onClick={onToggleSidebar}
className={`${TOOLBAR_BTN} ${isSidebarOpen ? TOOLBAR_BTN_ACTIVE : TOOLBAR_BTN_INACTIVE} shrink-0`} active={isSidebarOpen}
className="shrink-0"
aria-pressed={isSidebarOpen} aria-pressed={isSidebarOpen}
aria-label="Toggle sidebar" aria-label="Toggle sidebar"
title="Toggle sidebar" title="Toggle sidebar"
> >
<HamburgerIcon className="w-4 h-4" /> <HamburgerIcon className="w-4 h-4" />
</button> </ToolbarButton>
<div className={PILL}> <ToolbarGroup>
{VIEW_BUTTONS.map(({ value, label, Icon }) => { {VIEW_BUTTONS.map(({ value, label, Icon }) => {
const active = viewMode === value; const active = viewMode === value;
const isIconsToggle = value === 'icons'; const isIconsToggle = value === 'icons';
@ -117,110 +101,84 @@ export function FinderToolbar({
key={value} key={value}
className={isIconsToggle ? 'relative group/icons inline-flex items-center' : 'inline-flex items-center'} className={isIconsToggle ? 'relative group/icons inline-flex items-center' : 'inline-flex items-center'}
> >
<button <ToolbarSegment
type="button"
onClick={() => onViewModeChange(value)} onClick={() => onViewModeChange(value)}
active={active}
aria-pressed={active} aria-pressed={active}
aria-label={`${label} view`} aria-label={`${label} view`}
title={`${label} view`} title={`${label} view`}
className={ className="w-7"
PILL_SEGMENT +
' h-6 w-7 ' +
(active ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
}
> >
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
</button> </ToolbarSegment>
{isIconsToggle && viewMode === 'icons' && ( {isIconsToggle && viewMode === 'icons' && (
<div <div
className="absolute top-full left-1/2 z-30 -translate-x-1/2 pt-1 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/icons:opacity-100 group-hover/icons:pointer-events-auto group-focus-within/icons:opacity-100 group-focus-within/icons:pointer-events-auto" className="absolute top-full left-1/2 z-30 -translate-x-1/2 pt-1 opacity-0 pointer-events-none transition-opacity duration-fast group-hover/icons:opacity-100 group-hover/icons:pointer-events-auto group-focus-within/icons:opacity-100 group-focus-within/icons:pointer-events-auto"
> >
<div className={`${PILL} shadow-lg`}> <ToolbarGroup className="shadow-elev-2">
{ICON_SIZES.map(({ value: sizeValue, label: sizeLabel }) => { {ICON_SIZES.map(({ value: sizeValue, label: sizeLabel }) => {
const sizeActive = iconSize === sizeValue; const sizeActive = iconSize === sizeValue;
return ( return (
<button <ToolbarSegment
key={sizeValue} key={sizeValue}
type="button"
onClick={() => onIconSizeChange(sizeValue)} onClick={() => onIconSizeChange(sizeValue)}
active={sizeActive}
aria-pressed={sizeActive} aria-pressed={sizeActive}
aria-label={`Icon size ${sizeLabel}`} aria-label={`Icon size ${sizeLabel}`}
className={ className="min-w-[26px] px-1.5 font-semibold tracking-wide"
PILL_SEGMENT +
' h-6 min-w-[26px] px-1.5 font-semibold tracking-wide ' +
(sizeActive ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
}
> >
{sizeLabel} {sizeLabel}
</button> </ToolbarSegment>
); );
})} })}
</div> </ToolbarGroup>
</div> </div>
)} )}
</div> </div>
); );
})} })}
</div> </ToolbarGroup>
{showSortControls && ( {showSortControls && (
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<button <ToolbarButton onClick={onSortDirectionToggle} className="whitespace-nowrap" title="Toggle sort direction">
type="button"
onClick={onSortDirectionToggle}
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} whitespace-nowrap`}
title="Toggle sort direction"
>
{directionLabel} {directionLabel}
</button> </ToolbarButton>
<Listbox value={sortBy} onChange={onSortByChange}> <Listbox value={sortBy} onChange={onSortByChange}>
<ListboxButton <SharedListboxButton tone="toolbar" className="gap-1 min-w-[86px] justify-between">
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 min-w-[90px] justify-between`}
>
<span>{currentSort.label}</span> <span>{currentSort.label}</span>
<ChevronUpDownIcon className="h-3 w-3 opacity-60" /> <ChevronUpDownIcon className="h-3 w-3 opacity-60" />
</ListboxButton> </SharedListboxButton>
<ListboxOptions <SharedListboxOptions anchor="bottom end" tone="compact">
anchor="bottom end"
className="z-50 mt-1 rounded-md bg-background border border-offbase shadow-lg p-1 focus:outline-none"
>
{SORT_OPTIONS.map((opt) => ( {SORT_OPTIONS.map((opt) => (
<ListboxOption <SharedListboxOption
key={opt.value} key={opt.value}
value={opt.value} value={opt.value}
className={({ active, selected }) => tone="compact"
`cursor-pointer select-none rounded-sm py-1.5 px-2.5 text-xs ${
active ? 'bg-offbase text-accent' : 'text-foreground'
} ${selected ? 'font-semibold' : ''}`
}
> >
{opt.label} {opt.label}
</ListboxOption> </SharedListboxOption>
))} ))}
</ListboxOptions> </SharedListboxOptions>
</Listbox> </Listbox>
</div> </div>
)} )}
<div className="flex-1 min-w-0" /> <div className="flex-1 min-w-0" />
<div className="hidden sm:flex items-center gap-1.5 px-2 py-1 rounded-md bg-background border border-offbase hover:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:border-accent transition-colors duration-200 ease-out w-[160px] md:w-[200px]"> <SearchField
<SearchIcon className="w-3.5 h-3.5 text-muted shrink-0" /> value={query}
<input onChange={(e) => onQueryChange(e.target.value)}
type="search" placeholder="Search"
value={query} className="hidden w-[160px] md:w-[200px] sm:flex"
onChange={(e) => onQueryChange(e.target.value)} icon={<SearchIcon className="w-3.5 h-3.5" />}
placeholder="Search" />
className="flex-1 min-w-0 bg-transparent outline-none text-xs text-foreground placeholder:text-muted"
/>
</div>
{rightSlot && ( {rightSlot && (
<div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-offbase ml-0.5"> <div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-line ml-0.5">
{rightSlot} {rightSlot}
</div> </div>
)} )}
</div> </Toolbar>
</div>
); );
} }

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'; import type { ReactNode } from 'react';
import { Fragment, useEffect, useState, type ReactNode } from 'react'; import { LibraryFrame, useIsNarrow } from '@/components/layout';
interface FinderWindowProps { interface FinderWindowProps {
toolbar: ReactNode; toolbar: ReactNode;
@ -14,22 +14,7 @@ interface FinderWindowProps {
onRequestSidebarClose?: () => void; onRequestSidebarClose?: () => void;
} }
const NARROW_QUERY = '(max-width: 767px)'; export { useIsNarrow };
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;
}
/** /**
* Finder-style file pane: sidebar + toolbar + content + status bar. * Finder-style file pane: sidebar + toolbar + content + status bar.
@ -43,60 +28,15 @@ export function FinderWindow({
sidebarOpen, sidebarOpen,
onRequestSidebarClose, onRequestSidebarClose,
}: FinderWindowProps) { }: FinderWindowProps) {
const isNarrow = useIsNarrow();
return ( return (
<div className="flex flex-col h-full w-full bg-background overflow-hidden"> <LibraryFrame
{toolbar} toolbar={toolbar}
sidebar={sidebar}
<div className="flex flex-1 min-h-0"> statusBar={statusBar}
{!isNarrow && sidebarOpen && ( sidebarOpen={sidebarOpen}
<div className="h-full">{sidebar}</div> onRequestSidebarClose={onRequestSidebarClose}
)} >
{children}
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden"> </LibraryFrame>
{children}
</div>
</div>
{statusBar}
{/* Mobile drawer */}
<Transition show={isNarrow && sidebarOpen} as={Fragment}>
<Dialog
onClose={onRequestSidebarClose ?? (() => undefined)}
className="relative z-40 md:hidden"
>
<TransitionChild
as={Fragment}
enter="transition-opacity duration-150"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-y-0 left-0 flex max-w-full">
<TransitionChild
as={Fragment}
enter="transition-transform duration-200 ease-out"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition-transform duration-150 ease-in"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<DialogPanel
className="w-[80vw] max-w-[280px] h-full bg-base shadow-xl"
>
{sidebar}
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
</div>
); );
} }

View file

@ -1,12 +1,12 @@
'use client'; 'use client';
import { Fragment, useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Button } from '@headlessui/react';
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie'; import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie';
import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
import { useDocuments } from '@/contexts/DocumentContext'; import { useDocuments } from '@/contexts/DocumentContext';
import type { BaseDocument } from '@/types/documents'; import type { BaseDocument } from '@/types/documents';
import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents'; import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
import { Button, ModalFrame, ModalTitle } from '@/components/ui';
type DexieMigrationModalProps = { type DexieMigrationModalProps = {
isOpen: boolean; isOpen: boolean;
@ -133,87 +133,54 @@ export function DexieMigrationModal({
} }
}, [loadLocalDexieDocs, onComplete, refreshDocuments]); }, [loadLocalDexieDocs, onComplete, refreshDocuments]);
if (!isOpen) return null;
return ( return (
<Transition appear show={isOpen} as={Fragment}> <ModalFrame
<Dialog as="div" className="relative z-[80]" onClose={() => (closeDisabled ? null : onComplete())}> open={isOpen}
<TransitionChild onClose={() => {
as={Fragment} if (!closeDisabled) onComplete();
enter="ease-out duration-300" }}
enterFrom="opacity-0" panelTestId="migration-modal"
enterTo="opacity-100" className="z-[80]"
leave="ease-in duration-200" >
leaveFrom="opacity-100" <ModalTitle className="mb-4">{title}</ModalTitle>
leaveTo="opacity-0" <div className="space-y-2">
> <p className="text-sm text-soft mb-2">
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" /> Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version.
</TransitionChild> {displayMissingCount > 0 ? (
<> {displayMissingCount} {displayMissingCount === 1 ? 'is' : 'are'} not here yet.</>
<div className="fixed inset-0 overflow-y-auto"> ) : null}
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4"> {' '}This app now stores documents on the server and keeps a local cache for speed.
<TransitionChild </p>
as={Fragment} {isUploading && (
enter="ease-out duration-300" <div className="space-y-1">
enterFrom="opacity-0 scale-95" <p className="text-xs text-soft">{status}</p>
enterTo="opacity-100 scale-100" <div className="h-2 w-full rounded bg-surface-sunken">
leave="ease-in duration-200" <div className="h-2 rounded bg-accent" style={{ width: `${Math.max(1, Math.round(progress))}%` }} />
leaveFrom="opacity-100 scale-100" </div>
leaveTo="opacity-0 scale-95"
>
<DialogPanel data-testid="migration-modal" className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground mb-4">
{title}
</DialogTitle>
<div className="space-y-2">
<p className="text-sm text-muted mb-2">
Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version.
{displayMissingCount > 0 ? (
<> {displayMissingCount} {displayMissingCount === 1 ? 'is' : 'are'} not here yet.</>
) : null}
{' '}This app now stores documents on the server and keeps a local cache for speed.
</p>
{isUploading && (
<div className="space-y-1">
<p className="text-xs text-muted">{status}</p>
<div className="h-2 w-full rounded bg-offbase">
<div className="h-2 rounded bg-accent" style={{ width: `${Math.max(1, Math.round(progress))}%` }} />
</div>
</div>
)}
{!isUploading && status ? <p className="text-xs text-red-500">{status}</p> : null}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button
data-testid="migration-skip-button"
onClick={handleSkip}
disabled={isUploading}
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
Skip
</Button>
<Button
onClick={handleUpload}
disabled={isUploading}
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background
disabled:opacity-50"
>
{isUploading ? 'Uploading…' : 'Upload'}
</Button>
</div>
</DialogPanel>
</TransitionChild>
</div> </div>
</div> )}
</Dialog> {!isUploading && status ? <p className="text-xs text-danger">{status}</p> : null}
</Transition> </div>
<div className="flex justify-end gap-3 mt-6">
<Button
data-testid="migration-skip-button"
variant="outline"
size="sm"
onClick={handleSkip}
disabled={isUploading}
>
Skip
</Button>
<Button
variant="primary"
size="sm"
onClick={handleUpload}
disabled={isUploading}
>
{isUploading ? 'Uploading…' : 'Upload'}
</Button>
</div>
</ModalFrame>
); );
} }

View file

@ -1,10 +1,11 @@
'use client'; 'use client';
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'; import { Menu, MenuButton, Transition } from '@headlessui/react';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons'; import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons';
import { ZoomControl } from '@/components/documents/ZoomControl'; import { ZoomControl } from '@/components/documents/ZoomControl';
import { UserMenu } from '@/components/auth/UserMenu'; import { UserMenu } from '@/components/auth/UserMenu';
import { IconButton, MenuActionItem, MenuItemsSurface, ToolbarButton } from '@/components/ui';
interface DocumentHeaderMenuProps { interface DocumentHeaderMenuProps {
zoomLevel: number; zoomLevel: number;
@ -47,45 +48,33 @@ export function DocumentHeaderMenu({
max={maxZoom} max={maxZoom}
/> />
{onOpenSegments && ( {onOpenSegments && (
<button <ToolbarButton
onClick={onOpenSegments} onClick={onOpenSegments}
className={`inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-in-out hover:scale-[1.09] ${ active={isSegmentsOpen}
isSegmentsOpen
? 'border-accent text-accent bg-offbase'
: 'border-offbase text-foreground hover:bg-offbase hover:text-accent'
}`}
aria-label={isSegmentsOpen ? 'Hide segments sidebar' : 'Open segments sidebar'} aria-label={isSegmentsOpen ? 'Hide segments sidebar' : 'Open segments sidebar'}
title={isSegmentsOpen ? 'Hide Segments' : 'Segments'} title={isSegmentsOpen ? 'Hide Segments' : 'Segments'}
> >
<ListIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09]" /> <ListIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
</button> </ToolbarButton>
)} )}
{showAudiobookExport && onOpenAudiobook && ( {showAudiobookExport && onOpenAudiobook && (
<button <ToolbarButton
onClick={onOpenAudiobook} onClick={onOpenAudiobook}
className={`inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-in-out hover:scale-[1.09] ${ active={isAudiobookOpen}
isAudiobookOpen
? 'border-accent text-accent bg-offbase'
: 'border-offbase text-foreground hover:bg-offbase hover:text-accent'
}`}
aria-label={isAudiobookOpen ? 'Hide audiobook export' : 'Open audiobook export'} aria-label={isAudiobookOpen ? 'Hide audiobook export' : 'Open audiobook export'}
title={isAudiobookOpen ? 'Hide Export Audiobook' : 'Export Audiobook'} title={isAudiobookOpen ? 'Hide Export Audiobook' : 'Export Audiobook'}
> >
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09]" /> <DownloadIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
</button> </ToolbarButton>
)} )}
<button <ToolbarButton
onClick={onOpenSettings} onClick={onOpenSettings}
className={`inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-in-out hover:scale-[1.09] ${ active={isSettingsOpen}
isSettingsOpen
? 'border-accent text-accent bg-offbase'
: 'border-offbase text-foreground hover:bg-offbase hover:text-accent'
}`}
aria-label={isSettingsOpen ? 'Hide settings' : 'Open settings'} aria-label={isSettingsOpen ? 'Hide settings' : 'Open settings'}
title={isSettingsOpen ? 'Hide Settings' : 'Settings'} title={isSettingsOpen ? 'Hide Settings' : 'Settings'}
> >
<FileSettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09]" /> <FileSettingsIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
</button> </ToolbarButton>
<UserMenu /> <UserMenu />
</div> </div>
); );
@ -95,35 +84,30 @@ export function DocumentHeaderMenu({
<div className="sm:hidden flex items-center"> <div className="sm:hidden flex items-center">
<Menu as="div" className="relative inline-block text-left"> <Menu as="div" className="relative inline-block text-left">
<MenuButton <MenuButton
className="inline-flex items-center justify-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent" as={IconButton}
tone="surface"
size="sm"
title="Menu" title="Menu"
> >
<DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" /> <DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-base ease-standard hover:text-accent" />
</MenuButton> </MenuButton>
<Transition <Transition
as={Fragment} as={Fragment}
enter="transition ease-out duration-100" enter="transition ease-standard duration-fast"
enterFrom="transform opacity-0 scale-95" enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100" enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75" leave="transition ease-standard duration-fast"
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
> >
<MenuItems className="absolute right-0 mt-2 min-w-max origin-top-right divide-y divide-offbase rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none z-50"> <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 */} {/* Zoom Controls Section */}
<div className="px-4 py-3"> <div className="px-4 py-3">
<p className="text-xs font-medium text-muted mb-2">Zoom / Padding</p> <p className="text-xs font-medium text-soft mb-2">Zoom / Padding</p>
<div className="flex justify-center"> <div className="flex justify-center">
<ZoomControl <ZoomControl
value={zoomLevel} value={zoomLevel}
onIncrease={() => { onIncrease={onZoomIncrease}
// We wrap in a handler to stop propagation if needed,
// but ZoomControl buttons handle their own clicks.
// However, Menu might close on click?
// Headless UI Menu closes on click inside MenuItem, but these are just buttons in a div.
// It should NOT close unless we click a MenuItem.
onZoomIncrease();
}}
onDecrease={onZoomDecrease} onDecrease={onZoomDecrease}
min={minZoom} min={minZoom}
max={maxZoom} max={maxZoom}
@ -134,52 +118,28 @@ export function DocumentHeaderMenu({
{/* Actions Section */} {/* Actions Section */}
<div className="p-1"> <div className="p-1">
{onOpenSegments && ( {onOpenSegments && (
<MenuItem> <MenuActionItem onClick={onOpenSegments} activeOverride={isSegmentsOpen}>
{({ active }) => ( <ListIcon className="h-4 w-4" />
<button {isSegmentsOpen ? 'Hide Segments' : 'Segments'}
onClick={onOpenSegments} </MenuActionItem>
className={`${active || isSegmentsOpen ? 'bg-offbase text-accent' : 'text-foreground'
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<ListIcon className="h-4 w-4" />
{isSegmentsOpen ? 'Hide Segments' : 'Segments'}
</button>
)}
</MenuItem>
)} )}
{showAudiobookExport && onOpenAudiobook && ( {showAudiobookExport && onOpenAudiobook && (
<MenuItem> <MenuActionItem onClick={onOpenAudiobook} activeOverride={isAudiobookOpen}>
{({ active }) => ( <DownloadIcon className="h-4 w-4" />
<button {isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
onClick={onOpenAudiobook} </MenuActionItem>
className={`${active || isAudiobookOpen ? 'bg-offbase text-accent' : 'text-foreground'
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<DownloadIcon className="h-4 w-4" />
{isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
</button>
)}
</MenuItem>
)} )}
<MenuItem> <MenuActionItem onClick={onOpenSettings} activeOverride={isSettingsOpen}>
{({ active }) => ( <FileSettingsIcon className="h-4 w-4" />
<button {isSettingsOpen ? 'Hide Settings' : 'Settings'}
onClick={onOpenSettings} </MenuActionItem>
className={`${active || isSettingsOpen ? 'bg-offbase text-accent' : 'text-foreground'
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<FileSettingsIcon className="h-4 w-4" />
{isSettingsOpen ? 'Hide Settings' : 'Settings'}
</button>
)}
</MenuItem>
</div> </div>
{/* Auth Section */} {/* Auth Section */}
<div className="p-2 border-t border-offbase flex justify-center"> <div className="p-2 border-t border-line-soft flex justify-center">
<UserMenu /> <UserMenu />
</div> </div>
</MenuItems> </MenuItemsSurface>
</Transition> </Transition>
</Menu> </Menu>
</div> </div>

View file

@ -1,9 +1,8 @@
'use client'; 'use client';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; import { useEffect, useState } from 'react';
import { Fragment, useEffect, useState } from 'react';
import { BaseDocument } from '@/types/documents'; import { BaseDocument } from '@/types/documents';
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { Button, ModalFrame, ModalTitle } from '@/components/ui';
interface DocumentSelectionModalProps { interface DocumentSelectionModalProps {
isOpen: boolean; isOpen: boolean;
@ -114,120 +113,83 @@ export function DocumentSelectionModal({
}; };
return ( return (
<Transition appear show={isOpen} as={Fragment}> <ModalFrame open={isOpen} onClose={onClose} size="lg" panelClassName="flex h-[80vh] flex-col" className="z-[60]">
<Dialog as="div" className="relative z-[60]" onClose={onClose}> <ModalTitle className="mb-4 flex flex-shrink-0 items-center justify-between">
<TransitionChild {title}
as={Fragment} {files.length > 0 && (
enter="ease-out duration-300" <div className="flex items-center text-sm font-normal">
enterFrom="opacity-0" <label className="flex items-center gap-2 cursor-pointer select-none text-soft hover:text-foreground transition-colors">
enterTo="opacity-100" <input
leave="ease-in duration-200" type="checkbox"
leaveFrom="opacity-100" className="rounded border-muted text-accent focus:ring-accent-line"
leaveTo="opacity-0" checked={allSelected}
> ref={input => {
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" /> if (input) input.indeterminate = isIndeterminate;
</TransitionChild> }}
onChange={(e) => handleSelectAll(e.target.checked)}
<div className="fixed inset-0 overflow-y-auto"> />
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4"> Select All
<TransitionChild </label>
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-2xl transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all flex flex-col h-[80vh]">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground mb-4 flex-shrink-0 flex justify-between items-center"
>
{title}
{files.length > 0 && (
<div className="flex items-center text-sm font-normal">
<label className="flex items-center gap-2 cursor-pointer select-none text-muted hover:text-foreground transition-colors">
<input
type="checkbox"
className="rounded border-muted text-accent focus:ring-accent"
checked={allSelected}
ref={input => {
if (input) input.indeterminate = isIndeterminate;
}}
onChange={(e) => handleSelectAll(e.target.checked)}
/>
Select All
</label>
</div>
)}
</DialogTitle>
<div className="flex-1 overflow-auto border border-offbase rounded-lg bg-background p-2 min-h-0">
{isLoading ? (
<DocumentSelectionSkeleton />
) : errorMessage ? (
<div className="flex items-center justify-center h-full text-red-500">{errorMessage}</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted">No documents found.</div>
) : (
<div className="space-y-0.5">
{files.map((file) => {
const isSelected = selectedIds.has(file.id);
return (
<div
key={file.id}
onClick={(e) => handleRowClick(e, file.id)}
className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm select-none
${isSelected ? 'bg-accent/10' : 'hover:bg-offbase'}
`}
>
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => handleCheckboxChange(file.id, e.target.checked)}
className="rounded border-muted text-accent focus:ring-accent"
/>
</div>
<div
className={`flex-1 truncate ${
isSelected ? 'text-accent font-medium' : 'text-foreground'
}`}
>
{file.name}
</div>
<div className="text-muted text-xs whitespace-nowrap">{formatSize(file.size)}</div>
</div>
);
})}
</div>
)}
</div>
<div className="mt-4 flex justify-end gap-3 flex-shrink-0">
<button
type="button"
className={buttonClass({ variant: 'outline', size: 'md' })}
onClick={onClose}
>
Cancel
</button>
<button
type="button"
className={buttonClass({ variant: 'primary', size: 'md' })}
onClick={handleConfirmClick}
disabled={isLoading || selectedCount === 0 || isProcessing}
>
{isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`}
</button>
</div>
</DialogPanel>
</TransitionChild>
</div> </div>
</div> )}
</Dialog> </ModalTitle>
</Transition>
<div className="flex-1 overflow-auto border border-line rounded-lg bg-background p-2 min-h-0">
{isLoading ? (
<DocumentSelectionSkeleton />
) : errorMessage ? (
<div className="flex items-center justify-center h-full text-danger">{errorMessage}</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center h-full text-soft">No documents found.</div>
) : (
<div className="space-y-0.5">
{files.map((file) => {
const isSelected = selectedIds.has(file.id);
return (
<div
key={file.id}
onClick={(e) => handleRowClick(e, file.id)}
className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm select-none ${
isSelected ? 'bg-accent-wash' : 'hover:bg-accent-wash'
}`}
>
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => handleCheckboxChange(file.id, e.target.checked)}
className="rounded border-muted text-accent focus:ring-accent-line"
/>
</div>
<div
className={`flex-1 truncate ${
isSelected ? 'text-accent font-medium' : 'text-foreground'
}`}
>
{file.name}
</div>
<div className="text-soft text-xs whitespace-nowrap">{formatSize(file.size)}</div>
</div>
);
})}
</div>
)}
</div>
<div className="mt-4 flex justify-end gap-3 flex-shrink-0">
<Button variant="outline" size="md" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
size="md"
onClick={handleConfirmClick}
disabled={isLoading || selectedCount === 0 || isProcessing}
>
{isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`}
</Button>
</div>
</ModalFrame>
); );
} }
@ -237,9 +199,9 @@ function DocumentSelectionSkeleton() {
<div className="h-full animate-pulse space-y-0.5" aria-label="Loading documents" aria-busy="true"> <div className="h-full animate-pulse space-y-0.5" aria-label="Loading documents" aria-busy="true">
{rows.map((_, index) => ( {rows.map((_, index) => (
<div key={index} className="flex items-center gap-3 px-3 py-2 rounded-md"> <div key={index} className="flex items-center gap-3 px-3 py-2 rounded-md">
<div className="h-4 w-4 rounded-sm bg-offbase border border-offbase" /> <div className="h-4 w-4 rounded-sm bg-surface-sunken border border-line" />
<div className="h-3.5 flex-1 rounded bg-offbase" /> <div className="h-3.5 flex-1 rounded bg-surface-sunken" />
<div className="h-3 w-14 rounded bg-offbase" /> <div className="h-3 w-14 rounded bg-surface-sunken" />
</div> </div>
))} ))}
</div> </div>

View file

@ -15,8 +15,7 @@ import {
clampSegmentPreloadSentenceLookahead, clampSegmentPreloadSentenceLookahead,
clampTtsSegmentMaxBlockLength, clampTtsSegmentMaxBlockLength,
} from '@/types/config'; } from '@/types/config';
import { Section, ToggleRow, CheckItem, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives'; import { IconButton, RangeInput, Section, ToggleRow, CheckItem, SegmentedControl } from '@/components/ui';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons'; import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf'; import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse'; import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
@ -51,8 +50,6 @@ const viewTypeTextMapping = [
{ id: 'scroll', name: 'Continuous Scroll' }, { id: 'scroll', name: 'Continuous Scroll' },
]; ];
const rangeInputClassName = 'w-full bg-offbase rounded-md appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-md [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-md [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent';
type RangeSettingProps = { type RangeSettingProps = {
label: string; label: string;
value: number; value: number;
@ -80,14 +77,13 @@ function RangeSetting({
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">{label}</label> <label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">{label}</label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <RangeInput
type="range"
min={min} min={min}
max={max} max={max}
step={step} step={step}
value={value} value={value}
onChange={(event) => onChange(Number(event.target.value))} onChange={(event) => onChange(Number(event.target.value))}
className={`flex-1 ${rangeInputClassName}`} className="flex-1"
/> />
<span className={`${valueWidth} text-xs font-semibold text-right text-foreground`}>{formatter(value)}</span> <span className={`${valueWidth} text-xs font-semibold text-right text-foreground`}>{formatter(value)}</span>
</div> </div>
@ -163,27 +159,13 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
> >
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">Page mode</label> <label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">Page mode</label>
<div <SegmentedControl
role="radiogroup" value={selectedView.id as ViewType}
aria-label="Page mode" options={viewTypeTextMapping.map((view) => ({ value: view.id as ViewType, label: view.name }))}
className={`${segmentedGroupClass} grid-cols-3`} onChange={(nextViewType) => updateConfigKey('viewType', nextViewType)}
> ariaLabel="Page mode"
{viewTypeTextMapping.map((view) => { className="grid-cols-3"
const active = selectedView.id === view.id; />
return (
<button
key={view.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => updateConfigKey('viewType', view.id as ViewType)}
className={segmentedButtonClass(active)}
>
{view.name}
</button>
);
})}
</div>
{selectedView.id === 'scroll' ? ( {selectedView.id === 'scroll' ? (
<p className="text-xs text-warning">Scroll mode may be slower on large PDFs.</p> <p className="text-xs text-warning">Scroll mode may be slower on large PDFs.</p>
) : null} ) : null}
@ -214,20 +196,20 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
action={ action={
<div className="flex flex-col items-end gap-1"> <div className="flex flex-col items-end gap-1">
<span className="flex items-center gap-1 text-muted"> <span className="flex items-center gap-1 text-muted">
<SparkleIcon className="h-3 w-3 text-accent/70" /> <SparkleIcon className="h-3 w-3 text-accent" />
<span className="text-xs">PP-DocLayout-V3</span> <span className="text-xs">PP-DocLayout-V3</span>
</span> </span>
<span className="flex items-center gap-1 text-xs text-muted"> <span className="flex items-center gap-1 text-xs text-muted">
<span>{pdf.parseStatus ?? 'pending'}</span> <span>{pdf.parseStatus ?? 'pending'}</span>
<button <IconButton
type="button" size="xs"
className={buttonClass({ variant: 'ghost', size: 'icon', className: '!h-5 !w-5 hover:scale-[1.1] shrink-0' })} className="shrink-0"
onClick={pdf.onForceReparse} onClick={pdf.onForceReparse}
disabled={isForceReparseDisabled(pdf.parseStatus)} disabled={isForceReparseDisabled(pdf.parseStatus)}
title="Force reparse" title="Force reparse"
> >
<RefreshIcon className={`h-3 w-3 ${isForceReparseDisabled(pdf.parseStatus) ? 'animate-spin' : ''}`} /> <RefreshIcon className={`h-3 w-3 ${isForceReparseDisabled(pdf.parseStatus) ? 'animate-spin' : ''}`} />
</button> </IconButton>
</span> </span>
</div> </div>
} }
@ -240,7 +222,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
disabled={pdf.parseStatus !== 'ready'} disabled={pdf.parseStatus !== 'ready'}
variant="flat" variant="flat"
/> />
<details className="rounded-md border border-offbase bg-base/50 px-3 py-2"> <details className="rounded-md border border-offbase bg-surface-solid px-3 py-2">
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wide text-muted"> <summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wide text-muted">
Skip Block Kinds While Reading Aloud Skip Block Kinds While Reading Aloud
</summary> </summary>

View file

@ -6,6 +6,7 @@ import { UploadIcon } from '@/components/icons/Icons';
import { useDocuments } from '@/contexts/DocumentContext'; import { useDocuments } from '@/contexts/DocumentContext';
import { uploadDocxAsPdf } from '@/lib/client/api/documents'; import { uploadDocxAsPdf } from '@/lib/client/api/documents';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { dropzoneSurfaceClass } from '@/components/ui';
interface DocumentUploaderProps { interface DocumentUploaderProps {
className?: string; className?: string;
@ -155,26 +156,7 @@ export function DocumentUploader({
noKeyboard: variant === 'overlay' noKeyboard: variant === 'overlay'
}); });
const containerBase = `group w-full rounded transform transition-all duration-200 ease-in-out ${ const isDisabled = isUploading || isConverting;
variant === 'compact' ? 'hover:scale-[1.01]' : ''
} ${
isUploading || isConverting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
} ${className}`;
const borderBgClass =
variant === 'compact'
? `${
isDragActive
? 'border border-accent bg-offbase text-accent'
: 'border border-dashed border-offbase text-foreground hover:border-accent hover:bg-offbase hover:text-accent'
}`
: `${
isDragActive
? 'border-2 border-dashed border-accent bg-base text-foreground'
: 'border-2 border-dashed border-muted bg-transparent text-foreground hover:border-accent hover:bg-base hover:scale-[1.01]'
}`;
const paddingClass = variant === 'compact' ? 'py-1 px-2 rounded-md' : 'py-5 px-3 rounded-lg';
if (variant === 'overlay') { if (variant === 'overlay') {
const rootProps = getRootProps(); const rootProps = getRootProps();
@ -183,19 +165,19 @@ export function DocumentUploader({
<input {...getInputProps()} /> <input {...getInputProps()} />
{children} {children}
{isDragActive && ( {isDragActive && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background/90 backdrop-blur-md pointer-events-none p-6"> <div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-surface backdrop-blur-md pointer-events-none p-6">
<div className="w-full h-full border-2 border-dashed border-accent rounded-xl flex flex-col items-center justify-center bg-base/60 text-center p-4"> <div className="w-full h-full border-2 border-dashed border-accent rounded-lg flex flex-col items-center justify-center bg-surface-solid text-center p-4">
<UploadIcon className="w-14 h-14 text-accent mb-4 animate-bounce" /> <UploadIcon className="w-14 h-14 text-accent mb-4" />
<p className="text-xl font-bold text-foreground mb-1.5"> <p className="text-xl font-bold text-foreground mb-1.5">
Drop files here to upload Drop files here to upload
</p> </p>
<p className="text-sm text-foreground/70"> <p className="text-sm text-soft">
{enableDocx {enableDocx
? 'Accepts PDF, EPUB, TXT, MD, or DOCX' ? 'Accepts PDF, EPUB, TXT, MD, or DOCX'
: 'Accepts PDF, EPUB, TXT, or MD'} : 'Accepts PDF, EPUB, TXT, or MD'}
</p> </p>
{error && ( {error && (
<p className="mt-3 text-sm text-red-500"> <p className="mt-3 text-sm text-danger">
Upload failed: {error} try again. Upload failed: {error} try again.
</p> </p>
)} )}
@ -203,7 +185,7 @@ export function DocumentUploader({
</div> </div>
)} )}
{!isDragActive && error && ( {!isDragActive && error && (
<div className="absolute inset-x-4 bottom-4 z-40 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-center text-sm text-red-500 pointer-events-none"> <div className="absolute inset-x-4 bottom-4 z-40 rounded-md border border-danger bg-danger-wash px-3 py-2 text-center text-sm text-danger pointer-events-none">
Upload failed: {error} try again. Upload failed: {error} try again.
</div> </div>
)} )}
@ -214,12 +196,17 @@ export function DocumentUploader({
return ( return (
<div <div
{...getRootProps()} {...getRootProps()}
className={`${containerBase} ${borderBgClass} ${paddingClass}`} className={dropzoneSurfaceClass({
variant: variant === 'compact' ? 'compact' : 'default',
active: isDragActive,
disabled: isDisabled,
className,
})}
> >
<input {...getInputProps()} /> <input {...getInputProps()} />
{variant === 'compact' ? ( {variant === 'compact' ? (
<div className="flex items-center gap-2 text-left w-full min-w-0"> <div className="flex items-center gap-2 text-left w-full min-w-0">
<UploadIcon className="w-3.5 h-3.5 text-muted group-hover:text-accent shrink-0 transition-colors duration-200" /> <UploadIcon className="w-3.5 h-3.5 text-soft group-hover:text-accent shrink-0 transition-colors duration-base" />
{isUploading ? ( {isUploading ? (
<p className="text-[12px] font-medium truncate flex-1">Uploading</p> <p className="text-[12px] font-medium truncate flex-1">Uploading</p>
) : isConverting ? ( ) : isConverting ? (
@ -229,13 +216,13 @@ export function DocumentUploader({
<p className="text-[12px] truncate flex-1"> <p className="text-[12px] truncate flex-1">
{isDragActive ? 'Drop files here' : 'Upload documents'} {isDragActive ? 'Drop files here' : 'Upload documents'}
</p> </p>
{error && <p className="text-[10px] text-red-500 truncate shrink-0">{error}</p>} {error && <p className="text-[10px] text-danger truncate shrink-0">{error}</p>}
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center text-center"> <div className="flex flex-col items-center justify-center text-center">
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-muted" /> <UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-soft" />
{isUploading ? ( {isUploading ? (
<p className="text-sm sm:text-lg font-semibold text-foreground">Uploading file...</p> <p className="text-sm sm:text-lg font-semibold text-foreground">Uploading file...</p>
) : isConverting ? ( ) : isConverting ? (
@ -245,10 +232,10 @@ export function DocumentUploader({
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground"> <p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
{isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'} {isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'}
</p> </p>
<p className="text-xs sm:text-sm text-muted"> <p className="text-xs sm:text-sm text-soft">
{enableDocx ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'} {enableDocx ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}
</p> </p>
{error && <p className="mt-2 text-sm text-red-500">{error}</p>} {error && <p className="mt-2 text-sm text-danger">{error}</p>}
</> </>
)} )}
</div> </div>

View file

@ -1,4 +1,4 @@
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { IconButton } from '@/components/ui';
export function ZoomControl({ export function ZoomControl({
value, value,
@ -15,33 +15,25 @@ export function ZoomControl({
}) { }) {
return ( return (
<div className="flex items-center gap-1 select-none" aria-label="Zoom controls"> <div className="flex items-center gap-1 select-none" aria-label="Zoom controls">
<button <IconButton
type="button"
onClick={onDecrease} onClick={onDecrease}
disabled={value <= min} disabled={value <= min}
className={buttonClass({ size="sm"
variant: 'ghost', className="h-6 w-6 text-sm leading-none"
size: 'icon',
className: 'h-6 w-6 text-sm leading-none hover:scale-[1.09]',
})}
aria-label="Zoom out" aria-label="Zoom out"
> >
</button> </IconButton>
<span className="text-xs tabular-nums w-12 text-center text-muted">{value}%</span> <span className="text-xs tabular-nums w-12 text-center text-soft">{value}%</span>
<button <IconButton
type="button"
onClick={onIncrease} onClick={onIncrease}
disabled={value >= max} disabled={value >= max}
className={buttonClass({ size="sm"
variant: 'ghost', className="h-6 w-6 text-sm leading-none"
size: 'icon',
className: 'h-6 w-6 text-sm leading-none hover:scale-[1.09]',
})}
aria-label="Zoom in" aria-label="Zoom in"
> >
</button> </IconButton>
</div> </div>
); );
} }

View file

@ -1,301 +0,0 @@
'use client';
import { useId, type ReactNode } from 'react';
import {
btnBase,
btnDanger,
btnGhost,
btnOutline,
btnPrimary,
btnSecondary,
buttonClass,
type ButtonSize,
type ButtonVariant,
} from '@/components/ui/buttonPrimitives';
/**
* Shared compact form primitives used by settings-like surfaces across
* the app (settings modal, document settings, and admin panels).
*/
export {
btnBase,
btnDanger,
btnGhost,
btnOutline,
btnPrimary,
btnSecondary,
buttonClass,
type ButtonSize,
type ButtonVariant,
};
// Inputs use `bg-base` so they remain visible regardless of whether the
// surrounding container is `bg-background` (Card) or `bg-base` (Section).
// Using the same `bg-background` as the Card would make the input blend in.
// (Note: never use Tailwind alpha modifiers on these theme variables — they
// resolve to CSS custom properties that don't accept opacity suffixes.)
export const inputClass =
'w-full rounded-md bg-background border border-offbase px-2.5 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent';
export const listboxButtonClass =
'relative w-full cursor-pointer rounded-md bg-background border border-offbase py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent hover:bg-base transition-transform duration-200 ease-out hover:scale-[1.01]';
export const listboxOptionsClass =
'z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background p-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]';
export const listboxOptionClass = (active: boolean) =>
`relative cursor-pointer select-none rounded-sm py-1.5 pl-9 pr-3 text-sm ${active ? 'bg-offbase text-foreground' : 'text-foreground'}`;
export const segmentedGroupClass =
'grid gap-1 rounded-full border border-offbase bg-background p-1';
export const segmentedButtonClass = (active: boolean) =>
`rounded-full px-2.5 py-1.5 text-xs font-medium transition-colors transition-transform duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
active
? 'bg-accent text-background hover:scale-[1.01]'
: 'text-muted hover:bg-base hover:text-foreground hover:scale-[1.02]'
}`;
export function Section({
title,
subtitle,
children,
action,
variant = 'panel',
}: {
title: string;
subtitle?: ReactNode;
children: ReactNode;
action?: ReactNode;
variant?: 'panel' | 'flat';
}) {
if (variant === 'flat') {
return (
<section className="space-y-2 pb-3 border-b border-offbase last:border-b-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle ? <p className="text-xs text-muted mt-0.5">{subtitle}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
{children}
</section>
);
}
return (
<section className="rounded-xl border border-offbase bg-base overflow-hidden">
<div className="px-3 py-2 bg-background border-b border-offbase">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle ? <p className="text-xs text-muted mt-0.5">{subtitle}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
</div>
<div className="px-3 py-2 space-y-2">
{children}
</div>
</section>
);
}
export function Card({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={`rounded-lg border border-offbase bg-background px-3 py-2 transition-transform duration-200 ease-out hover:scale-[1.005] ${className}`}>
{children}
</div>
);
}
export type SwitchSize = 'sm' | 'md';
const SWITCH_SIZE: Record<SwitchSize, { track: string; thumb: string; on: string; off: string }> = {
sm: {
track: 'h-4 w-7',
thumb: 'h-3 w-3',
on: 'translate-x-3',
off: 'translate-x-0.5',
},
md: {
track: 'h-5 w-9',
thumb: 'h-4 w-4',
on: 'translate-x-4',
off: 'translate-x-0.5',
},
};
export function Switch({
checked,
onChange,
disabled = false,
size = 'md',
ariaLabel,
ariaLabelledBy,
ariaDescribedBy,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
size?: SwitchSize;
ariaLabel?: string;
ariaLabelledBy?: string;
ariaDescribedBy?: string;
}) {
const s = SWITCH_SIZE[size];
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex shrink-0 cursor-pointer items-center rounded-full border border-offbase transition-colors duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${s.track} ${
checked ? 'bg-accent' : 'bg-offbase'
}`}
>
<span
aria-hidden="true"
className={`pointer-events-none inline-block rounded-full bg-background shadow-sm ring-0 transition-transform duration-200 ease-out ${s.thumb} ${
checked ? s.on : s.off
}`}
/>
</button>
);
}
export function ToggleRow({
label,
description,
checked,
onChange,
disabled = false,
right,
variant = 'card',
}: {
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
right?: ReactNode;
variant?: 'card' | 'flat';
}) {
const labelId = useId();
const descId = useId();
const rowClass =
variant === 'flat'
? 'px-0.5 pt-1 pb-2 border-b border-offbase last:border-b-0 transition-transform duration-200 ease-out hover:scale-[1.003]'
: 'rounded-md border border-offbase bg-background px-2.5 py-1.5 transition-transform duration-200 ease-out hover:scale-[1.005]';
const handleTextToggle = () => {
if (!disabled) onChange(!checked);
};
return (
<div className={rowClass}>
<div className="flex items-start gap-2.5">
<div
className={`flex-1 min-w-0 space-y-0.5 ${disabled ? '' : 'cursor-pointer'}`}
onClick={handleTextToggle}
>
<span id={labelId} className="block text-sm font-medium leading-5 text-foreground">{label}</span>
<span id={descId} className="block text-xs leading-4 text-muted">{description}</span>
</div>
{right ? <div className="shrink-0 self-start pl-1.5">{right}</div> : null}
<Switch
checked={checked}
onChange={onChange}
disabled={disabled}
size="md"
ariaLabelledBy={labelId}
ariaDescribedBy={descId}
/>
</div>
</div>
);
}
export function CheckItem({
label,
checked,
onChange,
disabled = false,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) {
const labelId = useId();
const handleTextToggle = () => {
if (!disabled) onChange(!checked);
};
return (
<div className="flex items-center justify-between gap-2 py-0.5 group">
<span
id={labelId}
onClick={handleTextToggle}
className={`flex-1 min-w-0 truncate text-xs leading-4 text-foreground select-none transition-colors duration-200 ease-out group-hover:text-accent ${
disabled ? '' : 'cursor-pointer'
}`}
>
{label}
</span>
<Switch
checked={checked}
onChange={onChange}
disabled={disabled}
size="sm"
ariaLabelledBy={labelId}
/>
</div>
);
}
export function Field({
label,
hint,
className = '',
children,
}: {
label?: string;
hint?: string;
className?: string;
children: ReactNode;
}) {
return (
<div className={`space-y-1 ${className}`}>
{label ? <label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">{label}</label> : null}
{children}
{hint ? <p className="text-[11px] text-muted">{hint}</p> : null}
</div>
);
}
export function Badge({
tone = 'muted',
children,
}: {
tone?: 'muted' | 'accent' | 'foreground';
children: ReactNode;
}) {
const toneClass =
tone === 'accent'
? 'text-accent'
: tone === 'foreground'
? 'text-foreground bg-offbase'
: 'text-muted bg-offbase';
return (
<span className={`inline-flex items-center text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 ${toneClass}`}>
{children}
</span>
);
}

View file

@ -246,7 +246,7 @@ export function GithubIcon(props: React.SVGProps<SVGSVGElement>) {
export function PDFIcon(props: React.SVGProps<SVGSVGElement>) { export function PDFIcon(props: React.SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
className={`w-6 h-6 sm:w-8 sm:h-8 text-red-500 ${props.className || ''}`} className={`w-6 h-6 sm:w-8 sm:h-8 text-danger ${props.className || ''}`}
fill="currentColor" fill="currentColor"
stroke="currentColor" stroke="currentColor"
viewBox="-4 0 40 40" viewBox="-4 0 40 40"
@ -261,7 +261,7 @@ export function PDFIcon(props: React.SVGProps<SVGSVGElement>) {
export function EPUBIcon(props: React.SVGProps<SVGSVGElement>) { export function EPUBIcon(props: React.SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
className={`w-6 h-6 sm:w-8 sm:h-8 text-blue-500 ${props.className || ''}`} className={`w-6 h-6 sm:w-8 sm:h-8 text-accent ${props.className || ''}`}
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -283,7 +283,7 @@ export function FileIcon(props: React.SVGProps<SVGSVGElement>) {
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
className={props.className || "w-8 h-8 text-muted"} className={props.className || "w-8 h-8 text-soft"}
{...props} {...props}
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />

View file

@ -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 (
<div className={cn('sticky top-0 z-40 w-full border-b border-line-soft bg-surface', className)} data-app-header>
<div className="px-2 sm:px-3 py-1 flex items-center justify-between gap-2 min-h-10">
<div className="flex items-center gap-2 min-w-0 flex-1">
{left}
{typeof title === 'string' ? (
<h1 className="text-xs md:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1>
) : (
title
)}
</div>
<div className="flex items-center gap-2 min-w-0 justify-end">{right}</div>
</div>
</div>
);
}

View file

@ -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 (
<div className={cn('app-shell h-dvh flex flex-col bg-background overflow-hidden', className)}>
{children}
</div>
);
}
export function AppMain({ children, className }: { children: ReactNode; className?: string }) {
return <main className={cn('flex-1 min-h-0 flex flex-col', className)}>{children}</main>;
}

View file

@ -0,0 +1,74 @@
'use client';
import { useEffect, useState, type ReactNode } from 'react';
import { cn } from '@/components/ui/cn';
import { DrawerFrame } from '@/components/ui';
const NARROW_QUERY = '(max-width: 767px)';
export function useIsNarrow(): boolean {
const [isNarrow, setIsNarrow] = useState(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 (
<div className={cn('flex h-full w-full flex-col overflow-hidden bg-surface-sunken', className)}>
{toolbar}
<div className="flex flex-1 min-h-0">
{!isNarrow && sidebarOpen && <div className="h-full">{sidebar}</div>}
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">{children}</div>
</div>
{statusBar}
{isNarrow && (
<LibrarySidebarDrawer open={sidebarOpen} onClose={onRequestSidebarClose}>
{sidebar}
</LibrarySidebarDrawer>
)}
</div>
);
}
function LibrarySidebarDrawer({
open,
onClose,
children,
}: {
open: boolean;
onClose?: () => void;
children: ReactNode;
}) {
return (
<div className="md:hidden">
<DrawerFrame open={open} onClose={onClose ?? (() => undefined)} side="left" className="w-[80vw] max-w-[280px]">
{children}
</DrawerFrame>
</div>
);
}

View file

@ -0,0 +1,3 @@
export * from './AppHeader';
export * from './AppShell';
export * from './LibraryFrame';

View file

@ -1,7 +1,8 @@
'use client'; 'use client';
import { Button, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; import { Popover } from '@headlessui/react';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { IconButton, Input, PopoverSurface, PopoverTrigger } from '@/components/ui';
export const Navigator = ({ currentPage, numPages, skipToLocation }: { export const Navigator = ({ currentPage, numPages, skipToLocation }: {
currentPage: number; currentPage: number;
@ -53,36 +54,34 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
return ( return (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
{/* Page back */} {/* Page back */}
<Button <IconButton
onClick={() => skipToLocation(currentPage - 1, true)} onClick={() => skipToLocation(currentPage - 1, true)}
disabled={currentPage <= 1} disabled={currentPage <= 1}
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 transform ease-in-out hover:scale-[1.09] hover:text-accent" className="rounded-full"
aria-label="Previous page" aria-label="Previous page"
> >
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7m0 0l7-7m-7 7h18" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7m0 0l7-7m-7 7h18" />
</svg> </svg>
</Button> </IconButton>
{/* Page number popup */} {/* Page number popup */}
<Popover className="relative mb-1"> <Popover className="relative mb-1">
<PopoverButton <PopoverTrigger className="rounded-full bg-surface-sunken px-2 py-0.5 text-xs" onClick={handlePopoverOpen}>
className="bg-offbase px-2 py-0.5 rounded-full focus:outline-none cursor-pointer hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={handlePopoverOpen}
>
<p className="text-xs whitespace-nowrap"> <p className="text-xs whitespace-nowrap">
{currentPage} / {numPages || 1} {currentPage} / {numPages || 1}
</p> </p>
</PopoverButton> </PopoverTrigger>
<PopoverPanel anchor="top" className="absolute z-50 bg-base p-3 rounded-md shadow-lg border border-offbase"> <PopoverSurface anchor="top">
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<div className="text-xs font-medium text-foreground">Go to page</div> <div className="text-xs font-medium text-foreground">Go to page</div>
<input <Input
ref={inputRef} ref={inputRef}
type="text" type="text"
inputMode="numeric" inputMode="numeric"
pattern="[0-9]*" pattern="[0-9]*"
className="w-20 px-2 py-1 text-xs text-accent bg-offbase rounded border-none outline-none appearance-none text-center" controlSize="sm"
className="w-20 appearance-none border-none text-center text-accent"
value={inputValue} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
onBlur={handleInputConfirm} onBlur={handleInputConfirm}
@ -90,22 +89,22 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
placeholder={currentPage.toString()} placeholder={currentPage.toString()}
aria-label="Page number" aria-label="Page number"
/> />
<div className="text-xs text-muted text-center">of {numPages || 1}</div> <div className="text-xs text-soft text-center">of {numPages || 1}</div>
</div> </div>
</PopoverPanel> </PopoverSurface>
</Popover> </Popover>
{/* Page forward */} {/* Page forward */}
<Button <IconButton
onClick={() => skipToLocation(currentPage + 1, true)} onClick={() => skipToLocation(currentPage + 1, true)}
disabled={currentPage >= (numPages || 1)} disabled={currentPage >= (numPages || 1)}
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 transform ease-in-out hover:scale-[1.09] hover:text-accent" className="rounded-full"
aria-label="Next page" aria-label="Next page"
> >
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg> </svg>
</Button> </IconButton>
</div> </div>
); );
} }

View file

@ -1,8 +1,8 @@
'use client'; 'use client';
import { Button } from '@headlessui/react';
import { PauseIcon } from '@/components/icons/Icons'; import { PauseIcon } from '@/components/icons/Icons';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
import { IconButton } from '@/components/ui';
export function RateLimitPauseButton() { export function RateLimitPauseButton() {
const { isPlaying, togglePlay } = useTTS(); const { isPlaying, togglePlay } = useTTS();
@ -12,14 +12,13 @@ export function RateLimitPauseButton() {
if (!isPlaying) return null; if (!isPlaying) return null;
return ( return (
<Button <IconButton
onClick={() => { onClick={() => {
if (isPlaying) togglePlay(); if (isPlaying) togglePlay();
}} }}
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Pause" aria-label="Pause"
> >
<PauseIcon className="w-5 h-5" /> <PauseIcon className="w-5 h-5" />
</Button> </IconButton>
); );
} }

View file

@ -1,10 +1,11 @@
'use client'; 'use client';
import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; import { Popover } from '@headlessui/react';
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { PopoverSurface, PopoverTrigger, RangeInput } from '@/components/ui';
export const SpeedControl = ({ export const SpeedControl = ({
setSpeedAndRestart, setSpeedAndRestart,
@ -88,16 +89,16 @@ export const SpeedControl = ({
return ( return (
<Popover className="relative"> <Popover className="relative">
<PopoverButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"> <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" /> <SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
<span className="sm:hidden">{compactTriggerLabel}</span> <span className="sm:hidden">{compactTriggerLabel}</span>
<span className="hidden sm:inline">{triggerLabel}</span> <span className="hidden sm:inline">{triggerLabel}</span>
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" /> <ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</PopoverButton> </PopoverTrigger>
<PopoverPanel anchor="top" className="absolute z-50 bg-base p-3 rounded-md shadow-lg border border-offbase"> <PopoverSurface anchor="top">
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
{!nativeSpeedSupported && ( {!nativeSpeedSupported && (
<div className="rounded-md border border-offbase bg-background px-2 py-1.5 text-[11px] text-muted"> <div className="rounded-md border border-line bg-background px-2 py-1.5 text-[11px] text-soft">
Native model speed is not available for this model. Native model speed is not available for this model.
</div> </div>
)} )}
@ -112,8 +113,7 @@ export const SpeedControl = ({
</span> </span>
<span className="text-xs">{max.toFixed(1)}x</span> <span className="text-xs">{max.toFixed(1)}x</span>
</div> </div>
<Input <RangeInput
type="range"
min={min} min={min}
max={max} max={max}
step={step} step={step}
@ -122,7 +122,6 @@ export const SpeedControl = ({
onMouseUp={handleVoiceSpeedChangeComplete} onMouseUp={handleVoiceSpeedChangeComplete}
onKeyUp={handleVoiceSpeedChangeComplete} onKeyUp={handleVoiceSpeedChangeComplete}
onTouchEnd={handleVoiceSpeedChangeComplete} onTouchEnd={handleVoiceSpeedChangeComplete}
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
/> />
</div> </div>
)} )}
@ -136,8 +135,7 @@ export const SpeedControl = ({
</span> </span>
<span className="text-xs">{max.toFixed(1)}x</span> <span className="text-xs">{max.toFixed(1)}x</span>
</div> </div>
<Input <RangeInput
type="range"
min={min} min={min}
max={max} max={max}
step={step} step={step}
@ -146,11 +144,10 @@ export const SpeedControl = ({
onMouseUp={handleAudioSpeedChangeComplete} onMouseUp={handleAudioSpeedChangeComplete}
onKeyUp={handleAudioSpeedChangeComplete} onKeyUp={handleAudioSpeedChangeComplete}
onTouchEnd={handleAudioSpeedChangeComplete} onTouchEnd={handleAudioSpeedChangeComplete}
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
/> />
</div> </div>
</div> </div>
</PopoverPanel> </PopoverSurface>
</Popover> </Popover>
); );
}; };

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
import { Button } from '@headlessui/react';
import { import {
PlayIcon, PlayIcon,
PauseIcon, PauseIcon,
@ -12,6 +11,7 @@ import { LoadingSpinner } from '@/components/Spinner';
import { VoicesControl } from '@/components/player/VoicesControl'; import { VoicesControl } from '@/components/player/VoicesControl';
import { SpeedControl } from '@/components/player/SpeedControl'; import { SpeedControl } from '@/components/player/SpeedControl';
import { Navigator } from '@/components/player/Navigator'; import { Navigator } from '@/components/player/Navigator';
import { IconButton } from '@/components/ui';
export default function TTSPlayer({ currentPage, numPages }: { export default function TTSPlayer({ currentPage, numPages }: {
currentPage?: number; currentPage?: number;
@ -31,7 +31,7 @@ export default function TTSPlayer({ currentPage, numPages }: {
} = useTTS(); } = useTTS();
return ( return (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar> <div className="sticky bottom-0 z-30 w-full border-t border-line-soft bg-surface" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10"> <div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10">
{/* Speed control */} {/* Speed control */}
<SpeedControl <SpeedControl
@ -49,32 +49,29 @@ export default function TTSPlayer({ currentPage, numPages }: {
)} )}
{/* Playback Controls */} {/* Playback Controls */}
<Button <IconButton
onClick={skipBackward} onClick={skipBackward}
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Skip backward" aria-label="Skip backward"
disabled={isProcessing} disabled={isProcessing}
> >
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon className="w-5 h-5" />} {isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon className="w-5 h-5" />}
</Button> </IconButton>
<Button <IconButton
onClick={togglePlay} onClick={togglePlay}
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label={isPlaying ? 'Pause' : 'Play'} aria-label={isPlaying ? 'Pause' : 'Play'}
disabled={isProcessing && !isPlaying} disabled={isProcessing && !isPlaying}
> >
{isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />} {isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
</Button> </IconButton>
<Button <IconButton
onClick={skipForward} onClick={skipForward}
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Skip forward" aria-label="Skip forward"
disabled={isProcessing} disabled={isProcessing}
> >
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon className="w-5 h-5" />} {isProcessing ? <LoadingSpinner /> : <SkipForwardIcon className="w-5 h-5" />}
</Button> </IconButton>
{/* Voice control */} {/* Voice control */}
<VoicesControl availableVoices={availableVoices} setVoiceAndRestart={setVoiceAndRestart} /> <VoicesControl availableVoices={availableVoices} setVoiceAndRestart={setVoiceAndRestart} />

View file

@ -2,15 +2,13 @@
import { import {
Listbox, Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from '@headlessui/react'; } from '@headlessui/react';
import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro'; import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog'; import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn } from '@/components/ui';
export function VoicesControlBase({ export function VoicesControlBase({
availableVoices, availableVoices,
@ -34,15 +32,16 @@ export function VoicesControlBase({
: 'bottom-full right-0 mb-1'; : 'bottom-full right-0 mb-1';
const buttonClass = variant === 'field' const buttonClass = variant === 'field'
? 'relative cursor-pointer rounded-lg bg-base py-1.5 pl-3 pr-10 text-left text-foreground focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent w-full' ? 'bg-surface pr-10'
: 'flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent'; : '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' const iconClass = variant === 'field'
? 'h-3.5 w-3.5 shrink-0' ? 'h-3.5 w-3.5 shrink-0'
: 'h-3 w-3 sm:h-3.5 sm:w-3.5'; : 'h-3 w-3 sm:h-3.5 sm:w-3.5';
const chevronClass = variant === 'field' const chevronClass = variant === 'field'
? 'h-4 w-4 text-muted' ? 'h-4 w-4 text-soft'
: 'h-2.5 w-2.5 sm:h-3 sm:w-3'; : 'h-2.5 w-2.5 sm:h-3 sm:w-3';
const providerModelPolicy = resolveTtsProviderModelPolicy({ const providerModelPolicy = resolveTtsProviderModelPolicy({
@ -85,7 +84,7 @@ export function VoicesControlBase({
if (availableVoices.length === 0) { if (availableVoices.length === 0) {
return ( return (
<div className="relative"> <div className="relative">
<div className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-muted text-xs sm:text-sm rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1"> <div className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-soft text-xs sm:text-sm rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
<AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" /> <AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
<span>No voices</span> <span>No voices</span>
</div> </div>
@ -121,7 +120,7 @@ export function VoicesControlBase({
} }
}} }}
> >
<ListboxButton className={buttonClass}> <SharedListboxButton tone={buttonTone} className={buttonClass}>
{variant === 'field' ? ( {variant === 'field' ? (
<> <>
<span className="flex items-center gap-2 truncate text-sm font-medium"> <span className="flex items-center gap-2 truncate text-sm font-medium">
@ -139,15 +138,14 @@ export function VoicesControlBase({
<ChevronUpDownIcon className={chevronClass} /> <ChevronUpDownIcon className={chevronClass} />
</> </>
)} )}
</ListboxButton> </SharedListboxButton>
<ListboxOptions className={`absolute ${dropdownPosition} z-50 ${dropdownWidth} !h-auto !min-h-0 !max-h-[50vh] overflow-y-auto overscroll-contain rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none`}> <SharedListboxOptions tone="default" className={cn('absolute !h-auto !min-h-0 !max-h-[50vh]', dropdownPosition, dropdownWidth)}>
{availableVoices.map((voiceId) => ( {availableVoices.map((voiceId) => (
<ListboxOption <SharedListboxOption
key={voiceId} key={voiceId}
value={voiceId} value={voiceId}
className={({ active, selected }) => inset="none"
`relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 flex items-center gap-2 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}` itemClassName="flex items-center gap-2 py-1 sm:py-2"
}
> >
{({ selected }) => ( {({ selected }) => (
<> <>
@ -159,13 +157,13 @@ export function VoicesControlBase({
<span className="text-xs sm:text-sm">{voiceId}</span> <span className="text-xs sm:text-sm">{voiceId}</span>
</> </>
)} )}
</ListboxOption> </SharedListboxOption>
))} ))}
</ListboxOptions> </SharedListboxOptions>
</Listbox> </Listbox>
) : ( ) : (
<Listbox value={currentVoice} onChange={onChangeVoice}> <Listbox value={currentVoice} onChange={onChangeVoice}>
<ListboxButton className={buttonClass}> <SharedListboxButton tone={buttonTone} className={buttonClass}>
{variant === 'field' ? ( {variant === 'field' ? (
<> <>
<span className="flex items-center gap-2 truncate text-sm font-medium"> <span className="flex items-center gap-2 truncate text-sm font-medium">
@ -183,20 +181,19 @@ export function VoicesControlBase({
<ChevronUpDownIcon className={chevronClass} /> <ChevronUpDownIcon className={chevronClass} />
</> </>
)} )}
</ListboxButton> </SharedListboxButton>
<ListboxOptions className={`absolute ${dropdownPosition} z-50 ${dropdownWidth} !h-auto !min-h-0 !max-h-[50vh] overflow-y-auto overscroll-contain rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none`}> <SharedListboxOptions tone="default" className={cn('absolute !h-auto !min-h-0 !max-h-[50vh]', dropdownPosition, dropdownWidth)}>
{availableVoices.map((voiceId) => ( {availableVoices.map((voiceId) => (
<ListboxOption <SharedListboxOption
key={voiceId} key={voiceId}
value={voiceId} value={voiceId}
className={({ active, selected }) => inset="none"
`relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}` itemClassName="py-1 sm:py-2"
}
> >
<span className="text-xs sm:text-sm">{voiceId}</span> <span className="text-xs sm:text-sm">{voiceId}</span>
</ListboxOption> </SharedListboxOption>
))} ))}
</ListboxOptions> </SharedListboxOptions>
</Listbox> </Listbox>
)} )}
</div> </div>

View file

@ -4,7 +4,7 @@ import { Fragment, type ReactNode } from 'react';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@/components/icons/Icons'; import { XCircleIcon } from '@/components/icons/Icons';
import { useReaderSidebarBounds } from '@/hooks/useReaderSidebarBounds'; import { useReaderSidebarBounds } from '@/hooks/useReaderSidebarBounds';
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { IconButton } from '@/components/ui';
interface ReaderSidebarShellProps { interface ReaderSidebarShellProps {
isOpen: boolean; isOpen: boolean;
@ -41,10 +41,10 @@ export function ReaderSidebarShell({
> >
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="transition-opacity ease-out duration-200" enter="transition-opacity ease-standard duration-base"
enterFrom="opacity-0" enterFrom="opacity-0"
enterTo="opacity-100" enterTo="opacity-100"
leave="transition-opacity ease-in duration-150" leave="transition-opacity ease-standard duration-fast"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
@ -58,38 +58,34 @@ export function ReaderSidebarShell({
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="transition ease-out duration-220" enter="transition ease-standard duration-base"
enterFrom="translate-x-full" enterFrom="translate-x-full"
enterTo="translate-x-0" enterTo="translate-x-0"
leave="transition ease-in duration-180" leave="transition ease-standard duration-base"
leaveFrom="translate-x-0" leaveFrom="translate-x-0"
leaveTo="translate-x-full" leaveTo="translate-x-full"
> >
<aside <aside
role="dialog" role="dialog"
aria-label={ariaLabel} aria-label={ariaLabel}
className={`reader-sidebar-panel absolute inset-y-0 right-0 sm:right-3 sm:top-3 sm:bottom-3 pointer-events-auto bg-base border-l sm:border border-offbase shadow-xl sm:rounded-xl flex flex-col ${panelClassName}`} className={`reader-sidebar-panel absolute inset-y-0 right-0 sm:right-3 sm:top-3 sm:bottom-3 pointer-events-auto bg-surface border-l sm:border border-line shadow-elev-3 sm:rounded-lg flex flex-col ${panelClassName}`}
> >
<div className="border-b border-offbase px-4 py-3 flex items-start justify-between gap-3"> <div className="border-b border-line-soft px-4 py-3 flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-sm font-medium text-foreground">{title}</h2> <h2 className="text-sm font-medium text-foreground">{title}</h2>
{subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null} {subtitle ? <p className="mt-0.5 text-xs text-soft">{subtitle}</p> : null}
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
{headerActions} {headerActions}
<button <IconButton
type="button"
onClick={onClose} onClick={onClose}
aria-label="Close" aria-label="Close"
title="Close" title="Close"
className={buttonClass({ tone="surface"
variant: 'secondary', className="text-soft"
size: 'icon',
className: 'h-8 w-8 text-muted',
})}
> >
<XCircleIcon className="w-4 h-4" /> <XCircleIcon className="w-4 h-4" />
</button> </IconButton>
</div> </div>
</div> </div>

View file

@ -1,14 +1,14 @@
'use client'; 'use client';
import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; 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 { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { Book } from 'epubjs'; import type { Book } from 'epubjs';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons'; import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
import { buttonClass } from '@/components/ui/buttonPrimitives'; import { Button, IconButton, PopoverSurface } from '@/components/ui';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator'; import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan'; import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
@ -99,7 +99,7 @@ function settingsAreEqual(a: TTSSegmentSettings | null, b: TTSSegmentSettings |
function statusColor(status: TTSSegmentVariant['status']): string { function statusColor(status: TTSSegmentVariant['status']): string {
if (status === 'completed') return 'bg-accent'; if (status === 'completed') return 'bg-accent';
if (status === 'error') return 'bg-red-500'; if (status === 'error') return 'bg-danger';
return 'bg-muted'; return 'bg-muted';
} }
@ -632,33 +632,27 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
const headerActions = ( const headerActions = (
<> <>
<button <Button
type="button"
onClick={() => void handleClearCache()} onClick={() => void handleClearCache()}
aria-label="Clear segments cache" aria-label="Clear segments cache"
title="Clear cache for listed segments" title="Clear cache for listed segments"
disabled={isClearingSegments} disabled={isClearingSegments}
className={buttonClass({ variant="secondary"
variant: 'secondary', size="xs"
size: 'xs', className="h-8 px-2 text-soft"
className: 'h-8 px-2 text-muted',
})}
> >
{isClearingSegments ? 'Clearing…' : 'Clear'} {isClearingSegments ? 'Clearing…' : 'Clear'}
</button> </Button>
<button <IconButton
type="button"
onClick={handleRefresh} onClick={handleRefresh}
aria-label="Refresh segments" aria-label="Refresh segments"
title="Refresh" title="Refresh"
className={buttonClass({ tone="surface"
variant: 'secondary', size="md"
size: 'icon', className="h-8 w-8 text-soft"
className: 'h-8 w-8 text-muted',
})}
> >
<RefreshIcon className="w-3.5 h-3.5" /> <RefreshIcon className="w-3.5 h-3.5" />
</button> </IconButton>
</> </>
); );
@ -672,8 +666,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
headerActions={headerActions} headerActions={headerActions}
bodyClassName="flex-1 overflow-y-auto px-0 py-0" bodyClassName="flex-1 overflow-y-auto px-0 py-0"
> >
<div className="px-4 py-2 border-b border-offbase"> <div className="px-4 py-2 border-b border-line-soft">
<div className="text-xs text-muted"> <div className="text-xs text-soft">
{hasLoadedManifest ? ( {hasLoadedManifest ? (
<> <>
{rowsToRender.length} indexed {rowsToRender.length} indexed
@ -687,9 +681,9 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
) : null} ) : null}
</> </>
) : isManifestLoading ? ( ) : isManifestLoading ? (
<div className="animate-pulse h-3 w-36 rounded bg-offbase" aria-label="Loading segment summary" aria-busy="true" /> <div className="animate-pulse h-3 w-36 rounded bg-surface-sunken" aria-label="Loading segment summary" aria-busy="true" />
) : hasManifestError ? ( ) : hasManifestError ? (
<span className="text-red-500">error</span> <span className="text-danger">error</span>
) : ( ) : (
<span></span> <span></span>
)} )}
@ -698,23 +692,23 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
<div ref={listRef} className="flex-1 overflow-y-auto"> <div ref={listRef} className="flex-1 overflow-y-auto">
{hasManifestError && ( {hasManifestError && (
<div className="px-4 py-6 text-sm text-red-500">{manifestErrorMessage}</div> <div className="px-4 py-6 text-sm text-danger">{manifestErrorMessage}</div>
)} )}
{isManifestLoading && ( {isManifestLoading && (
<SegmentsListSkeleton /> <SegmentsListSkeleton />
)} )}
{hasLoadedManifest && rowsToRender.length === 0 && ( {hasLoadedManifest && rowsToRender.length === 0 && (
<div className="px-4 py-10 flex flex-col items-center text-center gap-2"> <div className="px-4 py-10 flex flex-col items-center text-center gap-2">
<div className="text-sm font-medium text-muted"> <div className="text-sm font-medium text-soft">
No segments No segments
</div> </div>
<p className="text-sm text-muted leading-relaxed max-w-[24ch]"> <p className="text-sm text-soft leading-relaxed max-w-[24ch]">
Press play in the reader to generate audio segments. Press play in the reader to generate audio segments.
</p> </p>
</div> </div>
)} )}
{hasLoadedManifest && rowsToRender.length > 0 && ( {hasLoadedManifest && rowsToRender.length > 0 && (
<ul className="divide-y divide-offbase"> <ul className="divide-y divide-line-soft">
{rowsToRender.map(({ segmentIndex, sentenceText, row, isCurrentLocation, groupKey, groupLabel, isSynthesized }, rowIndex) => { {rowsToRender.map(({ segmentIndex, sentenceText, row, isCurrentLocation, groupKey, groupLabel, isSynthesized }, rowIndex) => {
const previousGroupKey = rowIndex > 0 ? rowsToRender[rowIndex - 1]?.groupKey : null; const previousGroupKey = rowIndex > 0 ? rowsToRender[rowIndex - 1]?.groupKey : null;
const showGroupHeader = previousGroupKey !== groupKey; const showGroupHeader = previousGroupKey !== groupKey;
@ -741,11 +735,11 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
<li <li
key={`${isSynthesized ? 'syn' : 'mfr'}::${groupKey}::${segmentIndex}::${rowIndex}`} key={`${isSynthesized ? 'syn' : 'mfr'}::${groupKey}::${segmentIndex}::${rowIndex}`}
data-active-segment={isCurrent ? 'true' : undefined} data-active-segment={isCurrent ? 'true' : undefined}
className={`relative px-4 py-3 ${isCurrent ? 'bg-offbase/40' : ''}`} className={`relative px-4 py-3 ${isCurrent ? 'bg-surface-sunken' : ''}`}
> >
{showGroupHeader && ( {showGroupHeader && (
<div className="mb-2 -mx-4 px-4 py-1.5 bg-offbase/30 border-y border-offbase"> <div className="mb-2 -mx-4 px-4 py-1.5 bg-surface-sunken border-y border-line">
<span className="text-[10px] uppercase tracking-[0.14em] text-muted"> <span className="text-[10px] uppercase tracking-[0.14em] text-soft">
{groupLabel} {groupLabel}
</span> </span>
</div> </div>
@ -758,7 +752,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
type="button" type="button"
onClick={() => { if (canJump) handleJump(segmentIndex, row.locator); }} onClick={() => { if (canJump) handleJump(segmentIndex, row.locator); }}
disabled={!canJump} disabled={!canJump}
className={`text-xs font-medium shrink-0 pt-0.5 ${canJump ? 'text-muted hover:text-accent' : 'text-muted/50 cursor-not-allowed'}`} className={`text-xs font-medium shrink-0 pt-0.5 ${canJump ? 'text-soft hover:text-accent' : 'text-faint cursor-not-allowed'}`}
title={canJump ? (playable ? 'Play this segment' : 'Jump to this segment') : 'Text not loaded yet'} title={canJump ? (playable ? 'Play this segment' : 'Jump to this segment') : 'Text not loaded yet'}
aria-label={`Segment ${segmentIndex + 1}`} aria-label={`Segment ${segmentIndex + 1}`}
> >
@ -772,9 +766,9 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
disabled={!canJump} disabled={!canJump}
className={`block w-full text-left ${canJump ? '' : 'cursor-not-allowed'}`} className={`block w-full text-left ${canJump ? '' : 'cursor-not-allowed'}`}
> >
<p className={`text-sm leading-snug ${isCurrent ? 'text-foreground' : 'text-foreground/90'} line-clamp-2`}> <p className={`text-sm leading-snug ${isCurrent ? 'text-foreground' : 'text-soft'} line-clamp-2`}>
{sentenceText || ( {sentenceText || (
<span className="text-muted italic text-xs"> <span className="text-soft italic text-xs">
[text not loaded press play to fetch] [text not loaded press play to fetch]
</span> </span>
)} )}
@ -787,7 +781,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
aria-label={`Status ${status}`} aria-label={`Status ${status}`}
title={status} title={status}
/> />
<span className="text-xs text-muted"> <span className="text-xs text-soft">
{formatDuration(activeVariant?.durationMs)} {formatDuration(activeVariant?.durationMs)}
</span> </span>
{isCurrent && isPlaying && ( {isCurrent && isPlaying && (
@ -796,7 +790,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
</span> </span>
)} )}
{!canJump && ( {!canJump && (
<span className="text-[10px] text-muted/80 border border-offbase rounded px-1 py-0.5"> <span className="text-[10px] text-faint border border-line rounded px-1 py-0.5">
not loaded not loaded
</span> </span>
)} )}
@ -820,10 +814,10 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
className={[ className={[
'max-w-full whitespace-normal break-words text-left leading-none text-[10px] px-1 py-0.5 rounded border transition-colors', 'max-w-full whitespace-normal break-words text-left leading-none text-[10px] px-1 py-0.5 rounded border transition-colors',
isActive isActive
? 'border-accent text-accent bg-offbase/60' ? 'border-accent text-accent bg-surface-sunken'
: known : known
? 'border-offbase text-muted hover:border-accent hover:text-accent' ? 'border-line text-soft hover:border-accent hover:text-accent'
: 'border-offbase text-muted opacity-60 cursor-not-allowed', : 'border-line text-soft opacity-60 cursor-not-allowed',
].join(' ')} ].join(' ')}
> >
{formatVoiceLabel(variant.settings)} {formatVoiceLabel(variant.settings)}
@ -854,21 +848,21 @@ function SegmentsListSkeleton() {
return ( return (
<div className="px-4 py-3"> <div className="px-4 py-3">
<div className="animate-pulse space-y-3" aria-label="Loading segments" aria-busy="true"> <div className="animate-pulse space-y-3" aria-label="Loading segments" aria-busy="true">
<div className="h-3 w-40 rounded bg-offbase" /> <div className="h-3 w-40 rounded bg-surface-sunken" />
{Array.from({ length: 8 }).map((_, index) => ( {Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="rounded-md border border-offbase bg-base px-3 py-2.5"> <div key={index} className="rounded-md border border-line bg-surface px-3 py-2.5">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="h-3.5 w-8 rounded bg-offbase mt-0.5 shrink-0" /> <div className="h-3.5 w-8 rounded bg-surface-sunken mt-0.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
<div className="h-3.5 w-11/12 rounded bg-offbase" /> <div className="h-3.5 w-11/12 rounded bg-surface-sunken" />
<div className="h-3.5 w-3/4 rounded bg-offbase" /> <div className="h-3.5 w-3/4 rounded bg-surface-sunken" />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-offbase" /> <div className="h-2 w-2 rounded-full bg-surface-sunken" />
<div className="h-3 w-12 rounded bg-offbase" /> <div className="h-3 w-12 rounded bg-surface-sunken" />
<div className="h-4 w-20 rounded bg-offbase" /> <div className="h-4 w-20 rounded bg-surface-sunken" />
</div> </div>
</div> </div>
<div className="h-6 w-6 rounded bg-offbase shrink-0" /> <div className="h-6 w-6 rounded bg-surface-sunken shrink-0" />
</div> </div>
</div> </div>
))} ))}
@ -882,7 +876,7 @@ function SegmentsListSkeletonRows() {
<div className="px-4 py-3 animate-pulse" aria-label="Loading more segments" aria-busy="true"> <div className="px-4 py-3 animate-pulse" aria-label="Loading more segments" aria-busy="true">
<div className="space-y-2"> <div className="space-y-2">
{Array.from({ length: 3 }).map((_, index) => ( {Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="h-12 rounded-md border border-offbase bg-base" /> <div key={index} className="h-12 rounded-md border border-line bg-surface" />
))} ))}
</div> </div>
</div> </div>
@ -893,24 +887,25 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
return ( return (
<Popover className="relative shrink-0"> <Popover className="relative shrink-0">
<PopoverButton <PopoverButton
as={IconButton}
size="sm"
aria-label="Segment metadata" aria-label="Segment metadata"
title="Metadata" title="Metadata"
className="inline-flex items-center justify-center w-7 h-7 rounded-md border border-transparent text-muted hover:bg-offbase hover:border-offbase hover:text-accent transition-colors"
> >
<InfoIcon className="w-3.5 h-3.5" /> <InfoIcon className="w-3.5 h-3.5" />
</PopoverButton> </PopoverButton>
<Transition <Transition
as={Fragment} as={Fragment}
enter="transition ease-out duration-150" enter="transition ease-standard duration-fast"
enterFrom="opacity-0 translate-y-1" enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0" enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-100" leave="transition ease-standard duration-fast"
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<PopoverPanel <PopoverSurface
anchor="bottom end" anchor="bottom end"
className="z-[60] w-[300px] mt-1 rounded-lg border border-offbase bg-base shadow-xl p-3" className="z-[60] w-[300px] mt-1"
> >
<dl className="space-y-2"> <dl className="space-y-2">
<Row label="locator"> <Row label="locator">
@ -925,7 +920,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
: `${row.locator.readerType || '?'} (legacy)`} : `${row.locator.readerType || '?'} (legacy)`}
</span> </span>
) : ( ) : (
<span className="text-muted text-[11px]">none</span> <span className="text-soft text-[11px]">none</span>
)} )}
</Row> </Row>
<Row label="variants"> <Row label="variants">
@ -934,9 +929,9 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
</span> </span>
</Row> </Row>
{row.variants.map((v) => ( {row.variants.map((v) => (
<div key={v.segmentId} className="border-t border-offbase pt-2"> <div key={v.segmentId} className="border-t border-line-soft pt-2">
<Row label="segment_id"> <Row label="segment_id">
<span className="font-mono text-[10px] text-muted break-all"> <span className="font-mono text-[10px] text-soft break-all">
{v.segmentId.slice(0, 16)} {v.segmentId.slice(0, 16)}
</span> </span>
</Row> </Row>
@ -963,7 +958,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
</div> </div>
))} ))}
</dl> </dl>
</PopoverPanel> </PopoverSurface>
</Transition> </Transition>
</Popover> </Popover>
); );
@ -972,7 +967,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
function Row({ label, children }: { label: string; children: ReactNode }) { function Row({ label, children }: { label: string; children: ReactNode }) {
return ( return (
<div className="grid grid-cols-[80px_1fr] gap-2 items-baseline"> <div className="grid grid-cols-[80px_1fr] gap-2 items-baseline">
<dt className="font-mono uppercase tracking-[0.16em] text-[9px] text-muted">{label}</dt> <dt className="font-mono uppercase tracking-[0.16em] text-[9px] text-soft">{label}</dt>
<dd className="min-w-0">{children}</dd> <dd className="min-w-0">{children}</dd>
</div> </div>
); );

View file

@ -0,0 +1,35 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { variants } from './variants';
export type BadgeTone = 'muted' | 'accent' | 'foreground' | 'danger';
export const badgeStyles = variants({
base: 'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
variants: {
tone: {
muted: 'bg-surface-sunken text-soft',
accent: 'bg-accent-wash text-accent',
foreground: 'bg-surface-sunken text-foreground',
danger: 'bg-danger-wash text-danger',
},
},
defaults: {
tone: 'muted',
},
});
export function Badge({
tone = 'muted',
className,
children,
...props
}: HTMLAttributes<HTMLSpanElement> & {
tone?: BadgeTone;
children: ReactNode;
}) {
return (
<span className={badgeStyles({ tone, className })} {...props}>
{children}
</span>
);
}

View file

@ -0,0 +1,114 @@
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';
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
export const buttonStyles = variants({
base: cn(
'inline-flex items-center justify-center font-medium disabled:cursor-not-allowed disabled:opacity-50',
focusRing,
motionColors,
),
variants: {
variant: {
primary: 'bg-accent text-background hover:bg-secondary-accent',
secondary: 'border border-line bg-surface text-foreground hover:bg-accent-wash',
outline: 'border border-line bg-surface-sunken text-foreground hover:bg-accent-wash hover:text-accent',
danger: 'border border-danger bg-danger text-background hover:bg-danger-strong hover:border-danger-strong',
ghost: 'bg-transparent text-foreground hover:bg-accent-wash hover:text-accent',
},
size: {
xs: 'h-6 rounded-md px-2 text-xs',
sm: 'h-7 rounded-md px-2.5 text-xs',
md: 'h-8 rounded-md px-3 text-sm',
lg: 'h-10 rounded-lg px-4 text-base',
icon: 'h-8 w-8 rounded-md p-0 text-sm',
},
},
defaults: {
variant: 'secondary',
size: 'md',
},
});
export const btnBase = cn(
'inline-flex items-center justify-center rounded-md text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50',
focusRing,
motionColors,
);
export const btnPrimary = buttonStyles({ variant: 'primary', size: 'md' });
export const btnSecondary = buttonStyles({ variant: 'secondary', size: 'md' });
export const btnOutline = buttonStyles({ variant: 'outline', size: 'md' });
export const btnDanger = buttonStyles({ variant: 'danger', size: 'md' });
export const btnGhost = buttonStyles({ variant: 'ghost', size: 'md' });
function buttonClass({
variant = 'secondary',
size = 'md',
className = '',
}: {
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
} = {}) {
return buttonStyles({ variant, size, className });
}
export function Button({
variant = 'secondary',
size = 'md',
className,
children,
type = 'button',
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
children: ReactNode;
}) {
return (
<button type={type} className={buttonClass({ variant, size, className })} {...props}>
{children}
</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>
);
}

View file

@ -1,38 +0,0 @@
export const btnBase =
'inline-flex items-center justify-center rounded-md text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transition-colors transition-transform duration-200 ease-out disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100';
export const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.03]`;
export const btnSecondary = `${btnBase} bg-base text-foreground border border-offbase hover:bg-offbase hover:scale-[1.03]`;
export const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-base hover:text-accent hover:scale-[1.02]`;
export const btnDanger = `${btnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02]`;
export const btnGhost = `${btnBase} bg-transparent text-foreground hover:bg-base hover:text-accent`;
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
const BUTTON_VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: btnPrimary,
secondary: btnSecondary,
outline: btnOutline,
danger: btnDanger,
ghost: btnGhost,
};
const BUTTON_SIZE_CLASS: Record<ButtonSize, string> = {
xs: 'h-6 px-2 text-xs rounded-md',
sm: 'h-7 px-2.5 text-xs rounded-md',
md: 'h-8 px-3 text-sm rounded-md',
lg: 'h-10 px-4 text-base rounded-lg',
icon: 'h-8 w-8 rounded-md p-0',
};
export function buttonClass({
variant = 'secondary',
size = 'md',
className = '',
}: {
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
} = {}) {
return [BUTTON_VARIANT_CLASS[variant], BUTTON_SIZE_CLASS[size], className].filter(Boolean).join(' ');
}

View file

@ -0,0 +1,32 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
import { focusRing, motionColors } from './tokens';
export function ChoiceTile({
selected = false,
children,
className,
type = 'button',
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
selected?: boolean;
children: ReactNode;
}) {
return (
<button
type={type}
aria-pressed={selected}
className={cn(
'flex items-center gap-2 rounded-lg border px-2 py-1.5 text-left',
'transition duration-base ease-standard',
focusRing,
motionColors,
selected ? 'border-accent-line' : 'border-line hover:border-accent-line',
className,
)}
{...props}
>
{children}
</button>
);
}

5
src/components/ui/cn.ts Normal file
View file

@ -0,0 +1,5 @@
export type ClassValue = string | false | null | undefined;
export function cn(...classes: ClassValue[]) {
return classes.filter(Boolean).join(' ');
}

View file

@ -0,0 +1,158 @@
'use client';
import { Fragment, type KeyboardEventHandler, type ReactNode } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { variants } from './variants';
import { cn } from './cn';
export type DialogSize = 'sm' | 'md' | 'lg' | 'xl';
export const dialogPanelStyles = variants({
base: 'w-full transform rounded-lg border border-line bg-surface text-left align-middle shadow-elev-3 transition',
variants: {
size: {
sm: 'max-w-md p-5',
md: 'max-w-md p-6',
lg: 'max-w-2xl p-6',
xl: 'max-w-4xl overflow-hidden',
},
},
defaults: {
size: 'md',
},
});
export function DialogShell({
children,
className,
size = 'md',
}: {
children: ReactNode;
className?: string;
size?: DialogSize;
}) {
return <div className={dialogPanelStyles({ size, className })}>{children}</div>;
}
export function ModalFrame({
open,
onClose,
children,
size = 'md',
className,
panelClassName,
panelTestId,
onKeyDown,
afterLeave,
}: {
open: boolean;
onClose: () => void;
children: ReactNode;
size?: DialogSize;
className?: string;
panelClassName?: string;
panelTestId?: string;
onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
afterLeave?: () => void;
}) {
return (
<Transition appear show={open} as={Fragment} afterLeave={afterLeave}>
<Dialog as="div" role={undefined} className={cn('relative z-50', className)} onClose={onClose} onKeyDown={onKeyDown}>
<TransitionChild
as={Fragment}
enter="ease-standard duration-slow"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-standard duration-base"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-standard duration-slow"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-standard duration-base"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel data-testid={panelTestId} className={dialogPanelStyles({ size, className: panelClassName })}>
{children}
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}
export function ModalTitle({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<DialogTitle as="h3" className={cn('text-lg font-semibold leading-6 text-foreground', className)}>
{children}
</DialogTitle>
);
}
export function DrawerFrame({
open,
onClose,
children,
side = 'left',
className,
}: {
open: boolean;
onClose: () => void;
children: ReactNode;
side?: 'left' | 'right';
className?: string;
}) {
const sideClass = side === 'left' ? 'left-0' : 'right-0';
const enterFrom = side === 'left' ? '-translate-x-full' : 'translate-x-full';
const leaveTo = enterFrom;
return (
<Transition show={open} as={Fragment}>
<Dialog onClose={onClose} className="relative z-40">
<TransitionChild
as={Fragment}
enter="transition-opacity duration-fast"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-fast"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className={cn('fixed inset-y-0 flex max-w-full', sideClass)}>
<TransitionChild
as={Fragment}
enter="transition-transform duration-base ease-standard"
enterFrom={enterFrom}
enterTo="translate-x-0"
leave="transition-transform duration-fast ease-standard"
leaveFrom="translate-x-0"
leaveTo={leaveTo}
>
<DialogPanel className={cn('h-full bg-surface shadow-elev-3', className)}>
{children}
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
);
}

View file

@ -0,0 +1,6 @@
import type { HTMLAttributes } from 'react';
import { cn } from './cn';
export function Divider({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div aria-hidden="true" className={cn('border-t border-line-soft', className)} {...props} />;
}

View file

@ -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 && 'pointer-events-none cursor-not-allowed opacity-50', className),
});
}

107
src/components/ui/field.tsx Normal file
View file

@ -0,0 +1,107 @@
'use client';
import { useId, type ReactNode } from 'react';
import { cn } from './cn';
import { Switch } from './switch';
export function Field({
label,
hint,
className,
children,
}: {
label?: string;
hint?: string;
className?: string;
children: ReactNode;
}) {
return (
<div className={cn('space-y-1', className)}>
{label ? <label className="block text-[11px] font-semibold uppercase tracking-wide text-faint">{label}</label> : null}
{children}
{hint ? <p className="text-[11px] text-faint">{hint}</p> : null}
</div>
);
}
export function ToggleRow({
label,
description,
checked,
onChange,
disabled = false,
right,
variant = 'card',
}: {
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
right?: ReactNode;
variant?: 'card' | 'flat';
}) {
const labelId = useId();
const descId = useId();
const rowClass =
variant === 'flat'
? 'px-0.5 pt-1 pb-2 border-b border-line-soft last:border-b-0 transition-colors duration-fast ease-standard'
: 'rounded-md border border-line bg-surface px-2.5 py-1.5 transition-colors duration-fast ease-standard';
const handleTextToggle = () => {
if (!disabled) onChange(!checked);
};
return (
<div className={rowClass}>
<div className="flex items-start gap-2.5">
<div
className={cn('flex-1 min-w-0 space-y-0.5', disabled ? '' : 'cursor-pointer')}
onClick={handleTextToggle}
>
<span id={labelId} className="block text-sm font-medium leading-5 text-foreground">{label}</span>
<span id={descId} className="block text-xs leading-4 text-soft">{description}</span>
</div>
{right ? <div className="shrink-0 self-start pl-1.5">{right}</div> : null}
<Switch
checked={checked}
onChange={onChange}
disabled={disabled}
size="md"
ariaLabelledBy={labelId}
ariaDescribedBy={descId}
/>
</div>
</div>
);
}
export function CheckItem({
label,
checked,
onChange,
disabled = false,
}: {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) {
const labelId = useId();
const handleTextToggle = () => {
if (!disabled) onChange(!checked);
};
return (
<div className="flex items-center justify-between gap-2 py-0.5 group">
<span
id={labelId}
onClick={handleTextToggle}
className={cn(
'flex-1 min-w-0 truncate text-xs leading-4 text-foreground select-none transition-colors duration-fast ease-standard group-hover:text-accent',
disabled ? '' : 'cursor-pointer',
)}
>
{label}
</span>
<Switch checked={checked} onChange={onChange} disabled={disabled} size="sm" ariaLabelledBy={labelId} />
</div>
);
}

View file

@ -0,0 +1,47 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import { cn } from './cn';
import { variants } from './variants';
import { focusRing, motionColors } from './tokens';
export type IconButtonTone = 'ghost' | 'surface' | 'danger';
export type IconButtonSize = 'xs' | 'sm' | 'md' | 'lg';
export const iconButtonStyles = variants({
base: cn('inline-flex items-center justify-center disabled:cursor-not-allowed disabled:opacity-50', focusRing, motionColors),
variants: {
tone: {
ghost: 'text-soft hover:bg-accent-wash hover:text-accent',
surface: 'border border-line bg-surface text-foreground hover:bg-accent-wash hover:text-accent',
danger: 'text-danger hover:bg-danger-wash',
},
size: {
xs: 'h-5 w-5 rounded-sm text-xs',
sm: 'h-7 w-7 rounded-md text-xs',
md: 'h-8 w-8 rounded-md text-sm',
lg: 'h-10 w-10 rounded-lg text-base',
},
},
defaults: {
tone: 'ghost',
size: 'md',
},
});
export const IconButton = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement> & {
children: ReactNode;
tone?: IconButtonTone;
size?: IconButtonSize;
}>(function IconButton({
className,
children,
tone = 'ghost',
size = 'md',
type = 'button',
...props
}, ref) {
return (
<button ref={ref} type={type} className={iconButtonStyles({ tone, size, className })} {...props}>
{children}
</button>
);
});

View file

@ -0,0 +1,24 @@
export * from './badge';
export * from './button';
export * from './choice-tile';
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 './search-field';
export * from './section';
export * from './select';
export * from './segmented-control';
export * from './sidebar';
export * from './sidebar-nav';
export * from './surface';
export * from './switch';
export * from './tokens';
export * from './toolbar';
export * from './variants';

View file

@ -0,0 +1,38 @@
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
import { cn } from './cn';
import { variants } from './variants';
import { motionColors } from './tokens';
export type InputControlSize = 'sm' | 'md' | 'lg';
export const inputStyles = variants({
base: cn('w-full border border-line bg-surface-sunken text-foreground placeholder:text-soft focus:border-accent-line focus:outline-none focus:ring-2 focus:ring-accent-line', motionColors),
variants: {
size: {
sm: 'rounded-md px-2 py-1 text-xs',
md: 'rounded-md px-2.5 py-1.5 text-sm',
lg: 'rounded-lg px-3 py-2 text-sm',
},
},
defaults: {
size: 'md',
},
});
export const inputClass = inputStyles();
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }>(function Input({
className,
controlSize = 'md',
...props
}, ref) {
return <input ref={ref} className={inputStyles({ size: controlSize, className })} {...props} />;
});
export function Textarea({
className,
controlSize = 'md',
...props
}: TextareaHTMLAttributes<HTMLTextAreaElement> & { controlSize?: InputControlSize }) {
return <textarea className={inputStyles({ size: controlSize, className })} {...props} />;
}

View file

@ -0,0 +1,66 @@
import { MenuItem, MenuItems } from '@headlessui/react';
import type { ComponentProps } from 'react';
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
const menuPanelClass = 'rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft';
export function Menu({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
return (
<div className={cn(menuPanelClass, className)} {...props}>
{children}
</div>
);
}
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>
);
}

View 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>
);
}

View file

@ -0,0 +1,64 @@
import type { CSSProperties, InputHTMLAttributes } from 'react';
import { cn } from './cn';
type RangeStyle = CSSProperties & {
'--range-progress'?: string;
};
function toNumber(value: string | number | readonly string[] | undefined, fallback: number): number {
if (typeof value === 'number') return Number.isFinite(value) ? value : fallback;
if (typeof value === 'string') {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
function resolveRangeProgress(props: InputHTMLAttributes<HTMLInputElement>): number {
const min = toNumber(props.min, 0);
const max = toNumber(props.max, 100);
const value = toNumber(props.value, min);
const span = max - min;
if (span <= 0) return 0;
const progress = ((value - min) / span) * 100;
return Math.min(100, Math.max(0, progress));
}
const rangeInputClass = cn(
'h-6 w-full cursor-pointer appearance-none bg-transparent [--range-track-h:0.5rem] [--range-thumb-h:1.25rem]',
'focus-visible:outline-none focus-visible:[&::-webkit-slider-thumb]:ring-4 focus-visible:[&::-webkit-slider-thumb]:ring-accent/25',
'focus-visible:[&::-moz-range-thumb]:ring-4 focus-visible:[&::-moz-range-thumb]:ring-accent/25',
'disabled:cursor-not-allowed disabled:opacity-60',
'[&::-webkit-slider-runnable-track]:h-[var(--range-track-h)] [&::-webkit-slider-runnable-track]:rounded-full',
'[&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--secondary-accent)_0%,var(--secondary-accent)_var(--range-progress),color-mix(in_srgb,var(--offbase)_82%,var(--background))_var(--range-progress),color-mix(in_srgb,var(--offbase)_82%,var(--background))_100%)]',
'[&::-webkit-slider-runnable-track]:shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_10%,transparent)]',
'[&::-webkit-slider-thumb]:mt-[calc((var(--range-track-h)-var(--range-thumb-h))/2)] [&::-webkit-slider-thumb]:h-[var(--range-thumb-h)] [&::-webkit-slider-thumb]:w-[var(--range-thumb-h)] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[color-mix(in_srgb,var(--background)_78%,white)]',
'[&::-webkit-slider-thumb]:bg-accent',
'[&::-webkit-slider-thumb]:transition-transform [&::-webkit-slider-thumb]:duration-150 [&::-webkit-slider-thumb]:ease-out',
'active:[&::-webkit-slider-thumb]:scale-[1.07]',
'[&::-moz-range-track]:h-[var(--range-track-h)] [&::-moz-range-track]:rounded-full [&::-moz-range-track]:border-0',
'[&::-moz-range-track]:bg-[color-mix(in_srgb,var(--offbase)_82%,var(--background))]',
'[&::-moz-range-progress]:h-[var(--range-track-h)] [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:border-0 [&::-moz-range-progress]:bg-secondary-accent',
'[&::-moz-range-thumb]:h-[var(--range-thumb-h)] [&::-moz-range-thumb]:w-[var(--range-thumb-h)] [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2',
'[&::-moz-range-thumb]:border-[color-mix(in_srgb,var(--background)_78%,white)] [&::-moz-range-thumb]:bg-accent',
'[&::-moz-range-thumb]:transition-transform [&::-moz-range-thumb]:duration-150 [&::-moz-range-thumb]:ease-out',
'active:[&::-moz-range-thumb]:scale-[1.07]',
);
function rangeInputClassName(className?: string) {
return cn(rangeInputClass, className);
}
export function RangeInput({
className,
style,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>) {
const rangeStyle: RangeStyle = {
...style,
'--range-progress': `${resolveRangeProgress(props)}%`,
};
return <input type="range" className={rangeInputClassName(className)} style={rangeStyle} {...props} />;
}

View file

@ -0,0 +1,31 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
import { motionColors } from './tokens';
export function SearchField({
icon,
className,
inputClassName,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> & {
icon?: ReactNode;
inputClassName?: string;
}) {
return (
<label
className={cn(
'flex min-w-0 items-center gap-1.5 rounded-md border border-line bg-surface-sunken px-2 py-1',
'focus-within:border-accent-line focus-within:ring-1 focus-within:ring-accent-line hover:border-accent-line',
motionColors,
className,
)}
>
{icon ? <span className="shrink-0 text-soft">{icon}</span> : null}
<input
type="search"
className={cn('min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-soft', inputClassName)}
{...props}
/>
</label>
);
}

View file

@ -0,0 +1,53 @@
import type { ReactNode } from 'react';
export function Section({
title,
subtitle,
children,
action,
variant = 'panel',
}: {
title: string;
subtitle?: ReactNode;
children: ReactNode;
action?: ReactNode;
variant?: 'panel' | 'flat';
}) {
if (variant === 'flat') {
return (
<section className="space-y-2 pb-3 border-b border-line-soft last:border-b-0">
<SectionHeading title={title} subtitle={subtitle} action={action} />
{children}
</section>
);
}
return (
<section className="rounded-lg border border-line bg-surface overflow-hidden">
<div className="px-3 py-2 bg-surface-solid border-b border-line-soft">
<SectionHeading title={title} subtitle={subtitle} action={action} />
</div>
<div className="px-3 py-2 space-y-2">{children}</div>
</section>
);
}
function SectionHeading({
title,
subtitle,
action,
}: {
title: string;
subtitle?: ReactNode;
action?: ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle ? <p className="text-xs text-soft mt-0.5">{subtitle}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}

View file

@ -0,0 +1,86 @@
'use client';
import { useRef, type KeyboardEvent, type ReactNode } from 'react';
import { cn } from './cn';
import { focusRing, motionColors } from './tokens';
export const segmentedGroupClass = 'grid gap-1 rounded-full border border-line bg-surface-sunken p-1';
export const segmentedButtonClass = (active: boolean) =>
cn(
'rounded-full px-2.5 py-1.5 text-xs font-medium',
focusRing,
motionColors,
active ? 'bg-accent text-background' : 'text-soft hover:bg-accent-wash hover:text-foreground',
);
export function SegmentedControl<T extends string>({
value,
options,
onChange,
ariaLabel,
className,
}: {
value: T;
options: Array<{ value: T; label: ReactNode }>;
onChange: (value: T) => void;
ariaLabel: string;
className?: string;
}) {
const buttonRefs = useRef<Array<HTMLButtonElement | null>>([]);
const focusOption = (index: number) => {
const option = options[index];
if (!option) return;
onChange(option.value);
buttonRefs.current[index]?.focus();
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
switch (event.key) {
case 'ArrowLeft':
case 'ArrowUp':
event.preventDefault();
focusOption((index - 1 + options.length) % options.length);
break;
case 'ArrowRight':
case 'ArrowDown':
event.preventDefault();
focusOption((index + 1) % options.length);
break;
case 'Home':
event.preventDefault();
focusOption(0);
break;
case 'End':
event.preventDefault();
focusOption(options.length - 1);
break;
default:
break;
}
};
return (
<div role="radiogroup" aria-label={ariaLabel} className={cn(segmentedGroupClass, className)}>
{options.map((option, index) => {
const active = value === option.value;
return (
<button
key={option.value}
ref={(el) => { buttonRefs.current[index] = el; }}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => onChange(option.value)}
onKeyDown={(event) => handleKeyDown(event, index)}
className={segmentedButtonClass(active)}
>
{option.label}
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,142 @@
'use client';
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
import type { ComponentProps } from 'react';
import { cn } from './cn';
import { CheckIcon, ChevronRightIcon } from '@/components/icons/Icons';
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';
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';
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';
const listboxOptionsClass =
cn(listboxPanelClass, 'w-[var(--button-width)] [--anchor-gap:0.25rem]');
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]';
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',
);
const listboxCompactOptionClass = (active: boolean, selected = false) =>
cn(
'relative cursor-pointer select-none rounded-sm px-2 py-1 text-xs',
active
? 'bg-accent-wash text-accent'
: selected
? 'bg-surface-sunken text-accent font-medium'
: 'text-foreground',
);
export function SharedListboxButton({
tone = 'default',
className,
children,
...props
}: ComponentProps<typeof ListboxButton> & {
tone?: 'default' | 'toolbar' | 'popover' | 'unstyled';
}) {
const baseClass = tone === 'toolbar'
? listboxToolbarButtonClass
: tone === 'popover'
? listboxPopoverButtonClass
: tone === 'unstyled'
? ''
: listboxButtonClass;
return (
<ListboxButton className={cn(baseClass, className)} {...props}>
{children}
</ListboxButton>
);
}
export function SharedListboxOptions({
tone = 'default',
className,
children,
...props
}: ComponentProps<typeof ListboxOptions> & {
tone?: 'default' | 'compact';
}) {
const baseClass = tone === 'compact' ? listboxCompactOptionsClass : listboxOptionsClass;
return (
<ListboxOptions className={cn(baseClass, className)} {...props}>
{children}
</ListboxOptions>
);
}
export function SharedListboxOption({
tone = 'default',
inset = 'check',
itemClassName,
children,
...props
}: Omit<ComponentProps<typeof ListboxOption>, 'className'> & {
tone?: 'default' | 'compact';
inset?: 'check' | 'none';
itemClassName?: string;
}) {
return (
<ListboxOption
className={({ active, selected }: { active: boolean; selected: boolean }) => cn(
tone === 'compact'
? listboxCompactOptionClass(active, selected)
: listboxOptionClass(active, selected, inset),
itemClassName,
)}
{...props}
>
{children}
</ListboxOption>
);
}
export function Select({
value,
onChange,
options,
}: {
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string }>;
}) {
const activeOption = options.find((option) => option.value === value) ?? options[0];
return (
<Listbox value={value} onChange={onChange}>
<SharedListboxButton>
<span>{activeOption?.label ?? 'Select'}</span>
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-soft">
<ChevronRightIcon className="h-4 w-4 rotate-90" aria-hidden="true" />
</span>
</SharedListboxButton>
<SharedListboxOptions anchor="bottom">
{options.map((option) => (
<SharedListboxOption key={option.value} value={option.value}>
{({ selected }) => (
<>
<span className="absolute left-2 flex items-center text-accent">
{selected ? <CheckIcon className="h-4 w-4" aria-hidden="true" /> : null}
</span>
<span>{option.label}</span>
</>
)}
</SharedListboxOption>
))}
</SharedListboxOptions>
</Listbox>
);
}

View file

@ -0,0 +1,214 @@
import { forwardRef, type AnchorHTMLAttributes, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode } from 'react';
import { cn } from './cn';
import { focusRing, motionColors } from './tokens';
export function SidebarNav({
children,
className,
layout = 'stack',
...props
}: HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
layout?: 'stack' | 'grid';
}) {
return (
<div
className={cn(
layout === 'grid' ? 'grid grid-cols-2 gap-1' : 'flex flex-col gap-0.5',
className,
)}
{...props}
>
{children}
</div>
);
}
export function SidebarNavGroup({
children,
action,
className,
isFirst = false,
...props
}: HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
action?: ReactNode;
isFirst?: boolean;
}) {
return (
<div
className={cn(
'px-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-soft font-semibold leading-none flex items-center justify-between',
isFirst ? 'pt-1.5' : 'pt-3',
className,
)}
{...props}
>
<span>{children}</span>
{action ? <span className="inline-flex shrink-0 items-center leading-none">{action}</span> : null}
</div>
);
}
export function sidebarNavItemClass({
active = false,
compact = false,
isDropTarget = false,
className,
}: {
active?: boolean;
compact?: boolean;
isDropTarget?: boolean;
className?: string;
} = {}) {
return cn(
'group w-full min-w-0 border text-left font-medium',
'inline-flex items-center',
focusRing,
motionColors,
compact ? 'gap-1.5 rounded-md px-2 py-1 text-xs' : 'gap-2 rounded-md px-2.5 py-1.5 text-sm',
active
? 'border-accent-line bg-surface-sunken text-accent'
: 'border-transparent bg-transparent text-foreground hover:border-accent-line hover:bg-accent-wash hover:text-accent',
isDropTarget ? 'ring-1 ring-accent-line' : '',
className,
);
}
export function sidebarNavIconClass({
active = false,
compact = false,
}: {
active?: boolean;
compact?: boolean;
} = {}) {
return cn(
'shrink-0 inline-flex items-center justify-center transition-colors duration-base',
compact ? 'h-4 w-4' : 'h-5 w-5',
active ? 'text-accent' : 'text-soft group-hover:text-accent',
);
}
function SidebarNavItemContent({
active = false,
icon,
label,
count,
countClassName,
trailing,
compact = false,
children,
}: {
active?: boolean;
icon?: ReactNode;
label?: ReactNode;
count?: number;
countClassName?: string;
trailing?: ReactNode;
compact?: boolean;
children?: ReactNode;
}) {
return (
<>
{icon ? (
<span className={sidebarNavIconClass({ active, compact })}>
{icon}
</span>
) : null}
{label ?? children ? <span className="min-w-0 flex-1 truncate">{label ?? children}</span> : null}
{typeof count === 'number' && count > 0 ? (
<span className={cn('text-[10px] text-soft tabular-nums transition-transform duration-base ease-standard', countClassName)}>
{count}
</span>
) : null}
{trailing}
</>
);
}
export function SidebarNavItem({
active = false,
icon,
label,
count,
countClassName,
trailing,
isDropTarget = false,
className,
compact = false,
type = 'button',
children,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
active?: boolean;
icon?: ReactNode;
label?: ReactNode;
count?: number;
countClassName?: string;
trailing?: ReactNode;
isDropTarget?: boolean;
compact?: boolean;
}) {
return (
<button
type={type}
className={sidebarNavItemClass({ active, compact, isDropTarget, className })}
{...props}
>
<SidebarNavItemContent
active={active}
compact={compact}
icon={icon}
label={label}
count={count}
countClassName={countClassName}
trailing={trailing}
>
{children}
</SidebarNavItemContent>
</button>
);
}
export const SidebarNavLink = forwardRef<HTMLAnchorElement, AnchorHTMLAttributes<HTMLAnchorElement> & {
active?: boolean;
icon?: ReactNode;
label?: ReactNode;
count?: number;
countClassName?: string;
trailing?: ReactNode;
isDropTarget?: boolean;
compact?: boolean;
}>(function SidebarNavLink({
active = false,
icon,
label,
count,
countClassName,
trailing,
isDropTarget = false,
className,
compact = false,
children,
...props
}, ref) {
return (
<a
ref={ref}
className={sidebarNavItemClass({ active, compact, isDropTarget, className })}
{...props}
>
<SidebarNavItemContent
active={active}
compact={compact}
icon={icon}
label={label}
count={count}
countClassName={countClassName}
trailing={trailing}
>
{children}
</SidebarNavItemContent>
</a>
);
});

View file

@ -0,0 +1,17 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
export function Sidebar({
children,
className,
...props
}: HTMLAttributes<HTMLElement> & { children: ReactNode }) {
return (
<aside
className={cn('rounded-lg border border-line bg-surface text-foreground shadow-elev-2', className)}
{...props}
>
{children}
</aside>
);
}

View file

@ -0,0 +1,92 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
import { variants } from './variants';
import { motionSurface } from './tokens';
export type SurfaceTone = 'default' | 'solid' | 'sunken' | 'transparent';
export type SurfaceElevation = 'none' | '1' | '2' | '3';
export type SurfaceRadius = 'sm' | 'md' | 'lg' | 'pill';
export const surfaceStyles = variants({
base: 'border text-foreground',
variants: {
tone: {
default: 'border-line bg-surface',
solid: 'border-line bg-surface-solid',
sunken: 'border-line bg-surface-sunken',
transparent: 'border-transparent bg-transparent',
},
elevation: {
none: '',
'1': 'shadow-elev-1',
'2': 'shadow-elev-2',
'3': 'shadow-elev-3',
},
radius: {
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
pill: 'rounded-pill',
},
},
defaults: {
tone: 'default',
elevation: 'none',
radius: 'lg',
},
});
export function Surface({
children,
className,
tone = 'default',
elevation = 'none',
radius = 'lg',
...props
}: HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
tone?: SurfaceTone;
elevation?: SurfaceElevation;
radius?: SurfaceRadius;
}) {
return (
<div className={surfaceStyles({ tone, elevation, radius, className })} {...props}>
{children}
</div>
);
}
export function Panel({
children,
className,
elevation = '1',
...props
}: HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
elevation?: SurfaceElevation;
}) {
return (
<Surface elevation={elevation} className={cn('overflow-hidden', className)} {...props}>
{children}
</Surface>
);
}
export function Card({
children,
className,
interactive = false,
...props
}: HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
interactive?: boolean;
}) {
return (
<Surface
className={cn('px-3 py-2', interactive && motionSurface, className)}
{...props}
>
{children}
</Surface>
);
}

View file

@ -0,0 +1,72 @@
'use client';
import { cn } from './cn';
import { focusRing, motionColors } from './tokens';
export type SwitchSize = 'sm' | 'md';
const SWITCH_SIZE: Record<SwitchSize, { track: string; thumb: string; on: string; off: string }> = {
sm: {
track: 'h-4 w-7',
thumb: 'h-3 w-3',
on: 'translate-x-3',
off: 'translate-x-0.5',
},
md: {
track: 'h-5 w-9',
thumb: 'h-4 w-4',
on: 'translate-x-4',
off: 'translate-x-0.5',
},
};
export function Switch({
checked,
onChange,
disabled = false,
size = 'md',
ariaLabel,
ariaLabelledBy,
ariaDescribedBy,
className,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
size?: SwitchSize;
ariaLabel?: string;
ariaLabelledBy?: string;
ariaDescribedBy?: string;
className?: string;
}) {
const s = SWITCH_SIZE[size];
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
'relative inline-flex shrink-0 cursor-pointer items-center rounded-full border border-line disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-accent' : 'bg-line-strong',
s.track,
focusRing,
motionColors,
className,
)}
>
<span
aria-hidden="true"
className={cn(
'pointer-events-none inline-block rounded-full bg-surface shadow-elev-1 ring-0 transition-transform duration-fast ease-standard',
checked ? s.on : s.off,
s.thumb,
)}
/>
</button>
);
}

View file

@ -0,0 +1,3 @@
export const focusRing = 'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background';
export const motionColors = 'transition-colors duration-fast ease-standard';
export const motionSurface = 'transition-[transform,box-shadow,border-color,background-color] duration-base ease-standard';

View file

@ -0,0 +1,83 @@
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
import { variants } from './variants';
import { motionColors } from './tokens';
export const toolbarButtonStyles = variants({
base: cn('inline-flex items-center rounded-md border px-2 py-1 text-xs', motionColors),
variants: {
active: {
true: 'border-accent-line bg-surface-sunken text-accent',
false: 'border-line bg-surface text-foreground hover:border-accent-line hover:bg-accent-wash hover:text-accent',
},
},
defaults: {
active: 'false',
},
});
export function Toolbar({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
return (
<div className={cn('sticky top-0 z-40 w-full border-b border-line-soft bg-surface', className)} {...props}>
<div className="px-2 sm:px-3 py-1 min-h-10 flex items-center gap-1.5 sm:gap-2">{children}</div>
</div>
);
}
export function ToolbarButton({
active = false,
className,
children,
type = 'button',
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
active?: boolean;
children: ReactNode;
}) {
return (
<button type={type} className={toolbarButtonStyles({ active: active ? 'true' : 'false', className })} {...props}>
{children}
</button>
);
}
export function ToolbarGroup({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
return (
<div className={cn('inline-flex shrink-0 items-center gap-0.5 rounded-md border border-line bg-surface p-0.5', className)} {...props}>
{children}
</div>
);
}
export function ToolbarSegment({
active = false,
className,
children,
type = 'button',
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
active?: boolean;
children: ReactNode;
}) {
return (
<button
type={type}
className={cn(
'inline-flex h-6 items-center justify-center rounded-sm text-xs transition-colors duration-base ease-standard',
active ? 'bg-surface-sunken text-accent' : 'text-soft hover:bg-accent-wash hover:text-accent',
className,
)}
{...props}
>
{children}
</button>
);
}

View file

@ -0,0 +1,25 @@
import { cn, type ClassValue } from './cn';
type VariantGroups = Record<string, Record<string, string>>;
type VariantSelection<T extends VariantGroups> = {
[K in keyof T]?: keyof T[K] | null | undefined;
};
export function variants<T extends VariantGroups>({
base,
variants: groups,
defaults,
}: {
base?: string;
variants: T;
defaults?: VariantSelection<T>;
}) {
return (selection: VariantSelection<T> & { className?: ClassValue } = {}) => {
const resolved = Object.keys(groups).map((key) => {
const groupKey = key as keyof T;
const value = selection[groupKey] ?? defaults?.[groupKey];
return value ? groups[groupKey][value as keyof T[typeof groupKey]] : '';
});
return cn(base, ...resolved, selection.className);
};
}

View file

@ -9,6 +9,7 @@ import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
import { useEPUBResize } from '@/hooks/epub/useEPUBResize'; import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
import { DotsVerticalIcon, ChevronLeftIcon, ChevronRightIcon } from '@/components/icons/Icons'; import { DotsVerticalIcon, ChevronLeftIcon, ChevronRightIcon } from '@/components/icons/Icons';
import type { EpubDocumentState } from '@/app/(app)/epub/[id]/useEpubDocument'; import type { EpubDocumentState } from '@/app/(app)/epub/[id]/useEpubDocument';
import { ToolbarButton } from '@/components/ui';
const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), { const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), {
ssr: false, ssr: false,
@ -158,42 +159,40 @@ export function EPUBViewer({ className = '', epubState }: EPUBViewerProps) {
return ( return (
<div className={`h-full flex flex-col relative z-0 ${className}`} ref={containerRef}> <div className={`h-full flex flex-col relative z-0 ${className}`} ref={containerRef}>
<div className="flex items-center justify-between px-2 py-1 border-b border-offbase bg-base text-xs text-muted"> <div className="flex items-center justify-between px-2 py-1 border-b border-line-soft bg-surface text-xs text-soft">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <ToolbarButton
type="button" type="button"
onClick={() => setIsTocOpen(open => !open)} onClick={() => setIsTocOpen(open => !open)}
className="inline-flex items-center py-1 px-1 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
aria-label={isTocOpen ? 'Hide chapters' : 'Show chapters'} aria-label={isTocOpen ? 'Hide chapters' : 'Show chapters'}
className="px-1"
> >
<DotsVerticalIcon className="w-4 h-4" /> <DotsVerticalIcon className="w-4 h-4" />
</button> </ToolbarButton>
<button <ToolbarButton
type="button" type="button"
onClick={() => handleLocationChanged('prev')} onClick={() => handleLocationChanged('prev')}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
aria-label="Previous section" aria-label="Previous section"
> >
<ChevronLeftIcon className="w-4 h-4" /> <ChevronLeftIcon className="w-4 h-4" />
</button> </ToolbarButton>
</div> </div>
{currDocPages !== undefined && typeof currDocPage === 'number' && ( {currDocPages !== undefined && typeof currDocPage === 'number' && (
<span className="px-2 tabular-nums"> <span className="px-2 tabular-nums">
{currDocPage} / {currDocPages} {currDocPage} / {currDocPages}
</span> </span>
)} )}
<button <ToolbarButton
type="button" type="button"
onClick={() => handleLocationChanged('next')} onClick={() => handleLocationChanged('next')}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
aria-label="Next section" aria-label="Next section"
> >
<ChevronRightIcon className="w-4 h-4" /> <ChevronRightIcon className="w-4 h-4" />
</button> </ToolbarButton>
</div> </div>
{isTocOpen && tocRef.current && tocRef.current.length > 0 && ( {isTocOpen && tocRef.current && tocRef.current.length > 0 && (
<div className="border-b border-offbase bg-background text-xs overflow-y-auto max-h-64 p-2"> <div className="border-b border-line-soft bg-background text-xs overflow-y-auto max-h-64 p-2">
<div className="font-semibold text-muted pb-1">Skip to chapters</div> <div className="font-semibold text-soft pb-1">Skip to chapters</div>
<div className="flex flex-wrap gap-1 w-full"> <div className="flex flex-wrap gap-1 w-full">
{tocRef.current.map((item, index) => ( {tocRef.current.map((item, index) => (
<button <button
@ -204,8 +203,8 @@ export function EPUBViewer({ className = '', epubState }: EPUBViewerProps) {
setIsTocOpen(false); setIsTocOpen(false);
}} }}
className=" className="
px-2 py-1 rounded-md font-medium text-foreground text-center bg-base px-2 py-1 rounded-md font-medium text-foreground text-center bg-surface
hover:bg-offbase hover:text-accent transition-colors duration-150 hover:bg-accent-wash hover:text-accent transition-colors duration-fast
whitespace-nowrap whitespace-nowrap
flex-1 min-w-[140px] flex-1 min-w-[140px]
" "

View file

@ -480,7 +480,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
return ( return (
<div <div
key={`${block.id}:${fragment.page}:${fragment.readingOrder}`} key={`${block.id}:${fragment.page}:${fragment.readingOrder}`}
className="absolute border border-accent/70 rounded-[2px]" className="absolute border border-accent-line rounded-sm"
style={{ style={{
left: `${leftPct}%`, left: `${leftPct}%`,
top: `${topPct}%`, top: `${topPct}%`,
@ -536,7 +536,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
pageNumber={i + 1} pageNumber={i + 1}
renderAnnotationLayer={true} renderAnnotationLayer={true}
renderTextLayer={i + 1 === currDocPage} renderTextLayer={i + 1 === currDocPage}
className="shadow-lg" className="shadow-elev-2"
scale={currentScale()} scale={currentScale()}
onRenderSuccess={() => { onRenderSuccess={() => {
lastRenderedLayoutKeyRef.current = layoutKey; lastRenderedLayoutKeyRef.current = layoutKey;
@ -562,7 +562,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
pageNumber={leftPage} pageNumber={leftPage}
renderAnnotationLayer={true} renderAnnotationLayer={true}
renderTextLayer={leftPage === currDocPage} renderTextLayer={leftPage === currDocPage}
className="shadow-lg" className="shadow-elev-2"
scale={currentScale()} scale={currentScale()}
onRenderSuccess={() => { onRenderSuccess={() => {
lastRenderedLayoutKeyRef.current = layoutKey; lastRenderedLayoutKeyRef.current = layoutKey;
@ -584,7 +584,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
pageNumber={rightPage} pageNumber={rightPage}
renderAnnotationLayer={true} renderAnnotationLayer={true}
renderTextLayer={rightPage === currDocPage} renderTextLayer={rightPage === currDocPage}
className="shadow-lg" className="shadow-elev-2"
scale={currentScale()} scale={currentScale()}
onRenderSuccess={() => { onRenderSuccess={() => {
lastRenderedLayoutKeyRef.current = layoutKey; lastRenderedLayoutKeyRef.current = layoutKey;

View file

@ -18,6 +18,39 @@ export default {
accent: "var(--accent)", accent: "var(--accent)",
"secondary-accent": "var(--secondary-accent)", "secondary-accent": "var(--secondary-accent)",
muted: "var(--muted)", muted: "var(--muted)",
surface: "var(--surface)",
"surface-solid": "var(--surface-solid)",
"surface-sunken": "var(--surface-sunken)",
line: "var(--line)",
"line-soft": "var(--line-soft)",
"line-strong": "var(--line-strong)",
soft: "var(--soft)",
faint: "var(--faint)",
"accent-wash": "var(--accent-wash)",
"accent-line": "var(--accent-line)",
"accent-strong": "var(--accent-strong)",
danger: "var(--danger)",
"danger-strong": "var(--danger-strong)",
"danger-wash": "var(--danger-wash)",
},
boxShadow: {
"elev-1": "var(--elev-1)",
"elev-2": "var(--elev-2)",
"elev-3": "var(--elev-3)",
},
borderRadius: {
sm: "6px",
md: "10px",
lg: "14px",
pill: "999px",
},
transitionDuration: {
fast: "var(--dur-fast)",
base: "var(--dur-base)",
slow: "var(--dur-slow)",
},
transitionTimingFunction: {
standard: "var(--ease)",
}, },
animation: { animation: {
'spin-slow': 'spin 2s linear infinite', 'spin-slow': 'spin 2s linear infinite',

View file

@ -39,14 +39,18 @@ test.describe('Accessibility smoke', () => {
// Open the confirm dialog by clicking the row delete button // Open the confirm dialog by clicking the row delete button
await page.getByRole('button', { name: /^Delete sample\.pdf$/i }).first().click(); await page.getByRole('button', { name: /^Delete sample\.pdf$/i }).first().click();
// Title and dialog role visible // Title and dialog semantics are present
const heading = page.getByRole('heading', { name: 'Delete Document' }); const heading = page.getByRole('heading', { name: 'Delete Document' });
await expect(heading).toBeVisible({ timeout: 10000 }); await expect(heading).toBeVisible({ timeout: 10000 });
const dialog = heading.locator('xpath=ancestor::*[@role="dialog"][1]'); const dialog = page.getByRole('dialog', { name: 'Delete Document' }).first();
await expect(dialog).toBeVisible(); await expect(dialog).toHaveAttribute('aria-modal', 'true');
// The visible panel content is rendered and includes destructive action
const panel = page.getByTestId('confirm-dialog-panel').first();
await expect(panel).toBeVisible();
// Has a destructive action (Delete) // Has a destructive action (Delete)
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible(); await expect(panel.getByRole('button', { name: /^Delete$/ })).toBeVisible();
// Close with Escape to avoid deleting test data // Close with Escape to avoid deleting test data
await page.keyboard.press('Escape'); await page.keyboard.press('Escape');

View file

@ -497,37 +497,44 @@ export async function expectProcessingTransition(page: Page) {
// Expect navigator.mediaSession.playbackState to equal given state // Expect navigator.mediaSession.playbackState to equal given state
export async function expectMediaState(page: Page, state: 'playing' | 'paused') { export async function expectMediaState(page: Page, state: 'playing' | 'paused') {
// WebKit (and sometimes other engines) may not reliably update navigator.mediaSession.playbackState. // Engines can intermittently miss mediaSession updates. Accept either:
// Fallback heuristics: // - expected UI control state (Pause button for playing, Play button for paused), or
// 1. Prefer mediaSession if it matches desired state. // - underlying media state from mediaSession/audio element signals.
// 2. Otherwise inspect any <audio> element: use paused flag and currentTime progression. const desiredButtonName = state === 'playing' ? 'Pause' : 'Play';
// 3. Allow short grace period for first frame to advance. await expect
// 4. If neither detectable, keep polling until timeout. .poll(async () => {
await page.waitForFunction((desired) => { const uiMatches = await page
try { .getByRole('button', { name: desiredButtonName })
const msState = (navigator.mediaSession && navigator.mediaSession.playbackState) || ''; .first()
if (msState === desired) return true; .isVisible()
.catch(() => false);
if (uiMatches) return true;
const audio: HTMLAudioElement | null = document.querySelector('audio'); return page.evaluate((desired) => {
if (audio) { try {
// Track advancement by storing last time on the element dataset const msState = (navigator.mediaSession && navigator.mediaSession.playbackState) || '';
const last = parseFloat(audio.dataset.lastTime || '0'); if (msState === desired) return true;
const curr = audio.currentTime;
audio.dataset.lastTime = String(curr);
if (desired === 'playing') { const audio: HTMLAudioElement | null = document.querySelector('audio');
// Consider playing if not paused AND time has advanced at least a tiny amount if (audio) {
if (!audio.paused && curr > 0 && curr > last) return true; const w = window as Window & { __openreaderLastAudioTime?: number };
} else { const last = w.__openreaderLastAudioTime ?? -1;
// paused target const curr = audio.currentTime;
if (audio.paused) return true; w.__openreaderLastAudioTime = curr;
if (desired === 'playing') {
if (!audio.paused && curr > 0 && curr > last) return true;
} else if (audio.paused) {
return true;
}
}
return false;
} catch {
return false;
} }
} }, state);
return false; }, { timeout: 45000 })
} catch { .toBe(true);
return false;
}
}, state);
} }
// Use Navigator to go to a specific page number (PDF) // Use Navigator to go to a specific page number (PDF)