diff --git a/eslint.config.mjs b/eslint.config.mjs index 2dac3b5..2a0310d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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 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'])"; +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 = [ ...compat.extends("next/core-web-vitals", "next/typescript"), @@ -27,18 +124,25 @@ const eslintConfig = [ "no-restricted-imports": [ "error", { - 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'.", - }, - ], + patterns: COMPUTE_CORE_IMPORT_PATTERNS, + }, + ], + }, + }, + { + files: APP_DESIGN_SYSTEM_FILES, + rules: { + "no-restricted-syntax": ["error", ...RESTRICTED_APP_CLASS_PATTERNS], + }, + }, + { + files: UI_ARCHITECTURE_FILES, + rules: { + "no-restricted-imports": [ + "error", + { + paths: UI_ARCHITECTURE_IMPORT_PATHS, + patterns: COMPUTE_CORE_IMPORT_PATTERNS, }, ], }, diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index 19e1806..4a25c2e 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useParams, useRouter } from "next/navigation"; -import Link from 'next/link'; import { useCallback, useEffect, useRef, useState } from 'react'; import { DocumentSkeleton } from '@/components/documents/DocumentSkeleton'; import { EPUBViewer } from '@/components/views/EPUBViewer'; @@ -20,6 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; +import { ButtonLink } from '@/components/ui'; import { useEpubDocument } from './useEpubDocument'; export default function EPUBPage() { @@ -170,16 +170,13 @@ export default function EPUBPage() { if (error) { return (
-

{error}

- - +

{error}

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

{error}

- - +

{error}

+ + Back to Documents - +
); } @@ -175,16 +172,12 @@ export default function HTMLPage() { <>
- + + Documents - + } title={isLoading ? 'Loading…' : (currDocName || '')} right={ @@ -233,7 +226,7 @@ export default function HTMLPage() { /> )} {isAtLimit ? ( -
+
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index dcf5127..c048fbf 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react'; import { Toaster } from 'react-hot-toast'; import { Providers } from '@/app/providers'; +import { AppMain, AppShell } from '@/components/layout'; import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; export const dynamic = 'force-dynamic'; @@ -32,9 +33,9 @@ export default function AppLayout({ children }: { children: ReactNode }) { allowAnonymousAuthSessions={allowAnonymousAuthSessions} githubAuthEnabled={githubAuthEnabled} > -
-
{children}
-
+ + {children} + -

{error}

- - +

{error}

+ + Back to Documents - +
); } @@ -308,67 +304,68 @@ export default function PDFViewerPage() { : (isMerging ? 'Stage: merge' : 'Stage: infer')); return ( -
-
+
+
{showDetailedParseLoader ? ( -
+
-
+
- PDF Layout Parse + PDF Layout Parse

{statusText}

-
+

{hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'}

-

{stageLabel}

+

{stageLabel}

-
+
-

+

{hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : statusSubText}

{!isLoading && parseUiState === 'failed' ? (
- +
) : null}
) : ( -
+

{compactLabel}

-

{compactSubLabel}

+

{compactSubLabel}

- +
- - - + + +
)} @@ -381,17 +378,12 @@ export default function PDFViewerPage() { <>
- + + Documents - + } title={isLoading ? 'Loading…' : (currDocName || '')} right={ @@ -440,7 +432,7 @@ export default function PDFViewerPage() { /> )} {isAtLimit ? ( -
+
diff --git a/src/app/(app)/signin/page.tsx b/src/app/(app)/signin/page.tsx index fd46578..b5fb29b 100644 --- a/src/app/(app)/signin/page.tsx +++ b/src/app/(app)/signin/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState, useEffect, Suspense } from 'react'; -import { Button, Input } from '@headlessui/react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { getAuthClient } from '@/lib/client/auth-client'; @@ -10,7 +9,7 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { showPrivacyModal } from '@/components/PrivacyModal'; import { GithubIcon } from '@/components/icons/Icons'; 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 }) { const searchParams = useSearchParams(); @@ -120,11 +119,11 @@ function SignInContent() { -
+

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

-

+

{sessionExpired ? 'Please sign in again to continue' : 'Connect an email account to sync your data across devices'} @@ -132,45 +131,41 @@ function SignInContent() { {/* Alerts */} {sessionExpired && ( -

-

+

+

Your session has expired. Please sign in again.

)} {error && ( -
-

{error}

+
+

{error}

)}
{/* Email */} -
- + { setEmail(e.target.value); setError(null); }} placeholder="me@example.com" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm - focus:outline-none focus:ring-2 focus:ring-accent" + controlSize="lg" /> -
+ {/* Password */} -
- + { setPassword(e.target.value); setError(null); }} placeholder="Password" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm - focus:outline-none focus:ring-2 focus:ring-accent" + controlSize="lg" /> -
+ {/* Remember Me */} @@ -188,7 +183,9 @@ function SignInContent() { type="submit" disabled={isAnyLoading} onClick={handleSignIn} - className={buttonClass({ variant: 'primary', size: 'md', className: 'w-full hover:scale-[1.02]' })} + variant="primary" + size="md" + className="w-full" > {loadingEmail ? : 'Connect'} @@ -199,11 +196,9 @@ function SignInContent() { type="button" disabled={isAnyLoading} onClick={handleGithubSignIn} - className={buttonClass({ - variant: 'outline', - size: 'md', - className: 'w-full hover:scale-[1.02] flex items-center justify-center gap-2', - })} + variant="outline" + size="md" + className="w-full gap-2" > {loadingGithub ? ( @@ -222,7 +217,9 @@ function SignInContent() { type="button" disabled={isAnyLoading} onClick={handleAnonymousContinue} - className={buttonClass({ variant: 'outline', size: 'md', className: 'w-full hover:scale-[1.02]' })} + variant="outline" + size="md" + className="w-full" > {loadingAnonymous ? : 'Continue anonymously'} @@ -230,16 +227,16 @@ function SignInContent() {
{/* Footer */} -
+
{enableUserSignups && ( -

+

Don't have an account?{' '} Sign up

)} -

+

By signing in, you agree to our{' '}

-
+
); } diff --git a/src/app/(app)/signup/page.tsx b/src/app/(app)/signup/page.tsx index 9d9fbf1..f2b1ad8 100644 --- a/src/app/(app)/signup/page.tsx +++ b/src/app/(app)/signup/page.tsx @@ -1,7 +1,6 @@ 'use client'; import { useState } from 'react'; -import { Button, Input } from '@headlessui/react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { getAuthClient } from '@/lib/client/auth-client'; @@ -9,7 +8,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { showPrivacyModal } from '@/components/PrivacyModal'; import { LoadingSpinner } from '@/components/Spinner'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button, Field, IconButton, Input, Surface } from '@/components/ui'; import toast from 'react-hot-toast'; export default function SignUpPage() { @@ -105,77 +104,69 @@ export default function SignUpPage() { if (!enableUserSignups) { return (
-
+

Sign-ups unavailable

-

+

New account sign-ups are currently disabled by the site administrator.

-
-

+

+

Already have an account?{' '} Sign in

-
+
); } const { checks, strength } = validatePassword(password); 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 (
-
+

Sign Up

-

Create your account to get started

+

Create your account to get started

{error && ( -
-

{error}

+
+

{error}

)}
{/* Email */} -
- + setEmail(e.target.value)} placeholder="me@example.com" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm - focus:outline-none focus:ring-2 focus:ring-accent" + controlSize="lg" /> -
+ {/* Password */} -
- +
setPassword(e.target.value)} placeholder="Password" - className="w-full rounded-lg bg-background py-2 px-3 pr-10 text-foreground shadow-sm - focus:outline-none focus:ring-2 focus:ring-accent" + controlSize="lg" + className="pr-10" /> - +
{/* Password Strength */} @@ -185,17 +176,17 @@ export default function SignUpPage() { {[0, 1, 2, 3, 4].map((i) => (
))}
-

= 3 ? 'text-green-600' : 'text-red-600'}`}> +

= 3 ? 'text-accent' : 'text-danger'}`}> {strengthLabels[strength - 1] || 'Very Weak'}

-
+
{Object.entries(checks).map(([key, passed]) => ( -
+
{passed ? '✓' : '○'} {key === 'length' && 'At least 8 characters'} @@ -209,46 +200,46 @@ export default function SignUpPage() {
)} -
+ {/* Confirm Password */} -
- + setPasswordConfirmation(e.target.value)} placeholder="Confirm Password" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm - focus:outline-none focus:ring-2 focus:ring-accent" + controlSize="lg" /> {passwordConfirmation && password && ( -

+

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

)} -
+ {/* Sign Up Button */}
{/* Footer */} -
-

+

+

Already have an account?{' '} Sign in

-

+

By creating an account, you agree to our{' '}

-
+
); } diff --git a/src/app/(public)/layout.tsx b/src/app/(public)/layout.tsx index 7cc7024..f850899 100644 --- a/src/app/(public)/layout.tsx +++ b/src/app/(public)/layout.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import Link from 'next/link'; import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { ButtonAnchor, ButtonLink } from '@/components/ui'; import './public.css'; export default async function PublicLayout({ children }: { children: ReactNode }) { @@ -42,12 +42,8 @@ export default async function PublicLayout({ children }: { children: ReactNode } GitHub
@@ -70,18 +66,11 @@ export default async function PublicLayout({ children }: { children: ReactNode }

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

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

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

- + Open an Issue - - + + Self-Hosting Guide - - - Back to landing - + + Back to landing
diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts index 927c6ff..31af7fe 100644 --- a/src/app/api/documents/[id]/parsed/events/route.ts +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -10,7 +10,6 @@ import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state' import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state'; 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 { createRequestLogger, hashForLog } from '@/lib/server/logger'; 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() : null; - const testNamespace = getOpenReaderTestNamespace(req.headers); const storageUserId = authCtxOrRes.userId; const storageUserIdHash = hashForLog(storageUserId); const allowedUserIds = [storageUserId]; diff --git a/src/app/globals.css b/src/app/globals.css index 7c8b7db..284d34c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 { color: var(--foreground); background: var(--background); @@ -248,6 +299,17 @@ h1, h2, h3, h4, h5, h6 { 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. */ .pdf-viewer .react-pdf__message { display: none !important; diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index 314393e..0bd98b5 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -1,7 +1,7 @@ 'use client'; import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react'; -import { Transition, 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 { ProgressPopup } from '@/components/ProgressPopup'; import { ProgressCard } from '@/components/ProgressCard'; @@ -13,6 +13,7 @@ import { VoicesControlBase } from '@/components/player/VoicesControlBase'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; +import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, SharedListboxButton, SharedListboxOption, SharedListboxOptions } from '@/components/ui'; import { getAudiobookStatus, deleteAudiobookChapter, @@ -476,12 +477,12 @@ export function AudiobookExportModal({ <>
{!isGenerating && ( -
+
{/* Header */} -
+

Generation settings

{settingsLocked && ( - + Locked @@ -490,9 +491,9 @@ export function AudiobookExportModal({
{isLegacyAudiobookMissingSettings && ( -
+
Saved generation settings not found
-
+
This audiobook was likely created before v1 metadata was introduced, so OpenReader can't know which voice/speeds/format were used. Consider resetting this audiobook to regenerate it with v1 metadata (so settings are saved for resumes across devices). @@ -503,18 +504,18 @@ export function AudiobookExportModal({ {settingsLocked && savedSettings ? (
-
-
Voice
+ +
Voice
{savedSettings.voice}
-
-
-
Format
+ + +
Format
{savedSettings.format.toUpperCase()}
-
+
-
-
Native speed
+ +
Native speed
{resolveTtsProviderModelPolicy({ providerRef: savedSettings.providerRef, @@ -524,13 +525,13 @@ export function AudiobookExportModal({ ? `${formatSpeed(savedSettings.nativeSpeed)}x` : 'Not supported'}
-
-
-
Post speed
+ + +
Post speed
{formatSpeed(savedSettings.postSpeed)}x
-
+
-

+

Reset the audiobook to change generation settings.

@@ -539,7 +540,7 @@ export function AudiobookExportModal({ {/* Voice & Format row */}
- +
- + {chapters.length === 0 ? ( 0 || settingsLocked} >
- + {format.toUpperCase()} - + - + - - + - `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}` - } + inset="none" + itemClassName="py-2" > {({ selected }) => ( M4B )} - - + - `relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}` - } + inset="none" + itemClassName="py-2" > {({ selected }) => ( MP3 )} - - + +
@@ -608,9 +607,9 @@ export function AudiobookExportModal({
{/* Speed controls */} -
+ {!nativeSpeedSupported && ( -
+
Native model speed is not available for this model.
)} @@ -619,48 +618,44 @@ export function AudiobookExportModal({ <>
- + {formatSpeed(nativeSpeed)}x
- 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" /> -
+
0.5x 3x
-
+
)}
- + {formatSpeed(postSpeed)}x
- 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" /> -
+
0.5x 3x
-
+
)} @@ -670,10 +665,9 @@ export function AudiobookExportModal({ @@ -682,10 +676,9 @@ export function AudiobookExportModal({ @@ -694,10 +687,8 @@ export function AudiobookExportModal({
)} {showRegenerateHint && ( -
+

TTS audio for this chapter may be cached
Change the TTS playback options or restart the server to force uncached regeneration

- +
)} {/* Progress Info */} @@ -746,34 +737,36 @@ export function AudiobookExportModal({ >

Chapters

- {isRefreshingChapters && } + {isRefreshingChapters && }
{displayChapters.map((chapter) => (
{chapter.status === 'completed' ? ( ) : onRegenerateChapter ? ( - + ) : ( - + )}

{chapter.title}

-

+

{chapter.status !== 'completed' && Missing • }{formatDuration(chapter.duration)}

@@ -782,82 +775,63 @@ export function AudiobookExportModal({ {((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && ( - {chapter.status === 'completed' && ( <> - - {({ active }) => ( - - )} - - - {({ active }) => ( - - )} - + setPendingDeleteChapter(chapter)} + title="Delete this chapter" + > + + Delete + + handleDownloadChapter(chapter)}> + + Download + )} {regeneratingChapter === chapter.index && ( - - {({ active }) => ( - - )} - + + + Cancel + )} {onRegenerateChapter && !isGenerating && ( - - {({ active, disabled }) => ( - - )} - + handleRegenerateChapter(chapter)} + title="Regenerate this chapter" + > + + {regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'} + )} - + {/* end of menu items */} @@ -868,15 +842,13 @@ export function AudiobookExportModal({
{bookId && !isGenerating && ( -
+
+
); } diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 4dca7ff..374ec34 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,9 +1,10 @@ 'use client'; import { useRef, useState, useEffect, useCallback } from 'react'; -import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; +import { Popover, PopoverButton } from '@headlessui/react'; import { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext'; import { PaletteIcon } from '@/components/icons/Icons'; +import { IconButton, Input, PopoverSurface } from '@/components/ui'; /** * Curated swatch palettes per color role, sourced from existing themes @@ -78,9 +79,9 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps) return ( - +
- {/* Label */}
@@ -101,14 +101,14 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps) {/* Eyedropper / native picker */}
- + + 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={{ backgroundColor: color, 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', }} /> - setHexInput(e.target.value)} @@ -166,10 +166,11 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps) }} spellCheck={false} 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" />
- + ); } diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx index 582a400..8bdc0b9 100644 --- a/src/components/ConfirmDialog.tsx +++ b/src/components/ConfirmDialog.tsx @@ -1,6 +1,5 @@ -import { Fragment, KeyboardEvent } from 'react'; -import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { KeyboardEvent } from 'react'; +import { Button, ModalFrame, ModalTitle } from '@/components/ui'; interface ConfirmDialogProps { isOpen: boolean; @@ -31,77 +30,25 @@ export function ConfirmDialog({ }; return ( - - - + {title} +
+

{message}

+
+ +
+ + - -
- -
-
-
- - + {confirmText} + +
+ ); } diff --git a/src/components/CookieConsentBanner.tsx b/src/components/CookieConsentBanner.tsx index b62a0fd..de748e0 100644 --- a/src/components/CookieConsentBanner.tsx +++ b/src/components/CookieConsentBanner.tsx @@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; import { getConsentState, setConsentState } from '@/lib/client/analytics'; import { Transition } from '@headlessui/react'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button } from '@/components/ui'; export function CookieConsentBanner() { const [show, setShow] = useState(false); @@ -35,46 +35,42 @@ export function CookieConsentBanner() { -
+

🍪 We use cookies

-

+

We use strictly necessary cookies for authentication. Optional analytics is enabled only when you consent. See our Privacy Policy for details.

- - +
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index dfd8367..e6394ef 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,3 +1,4 @@ +import { AppHeader } from '@/components/layout'; import { ReactNode } from "react"; export function Header({ @@ -9,19 +10,5 @@ export function Header({ title?: ReactNode; right?: ReactNode; }) { - return ( -
-
-
- {left} - {typeof title === 'string' ? ( -

{title}

- ) : ( - title - )} -
-
{right}
-
-
- ); + return ; } diff --git a/src/components/HomeContent.tsx b/src/components/HomeContent.tsx index e548230..5774ed9 100644 --- a/src/components/HomeContent.tsx +++ b/src/components/HomeContent.tsx @@ -21,8 +21,8 @@ export function HomeContent() { const appActions = (
setSettingsOpen(true)} /> diff --git a/src/components/PrivacyModal.tsx b/src/components/PrivacyModal.tsx index b462a32..600c7c7 100644 --- a/src/components/PrivacyModal.tsx +++ b/src/components/PrivacyModal.tsx @@ -1,15 +1,8 @@ 'use client'; -import { Fragment, useState, useEffect } from 'react'; -import { - Dialog, - DialogPanel, - DialogTitle, - Transition, - TransitionChild, - Button, -} from '@headlessui/react'; +import { useState, useEffect } from 'react'; import { updateAppConfig } from '@/lib/client/dexie'; +import { Button, ModalFrame, ModalTitle } from '@/components/ui'; interface PrivacyModalProps { isOpen: boolean; @@ -19,9 +12,9 @@ interface PrivacyModalProps { function PrivacyModalBody({ origin }: { origin: string }) { return ( -
-
-
Service Operator
+
+
+
Service Operator
This instance is hosted at {origin || 'this server'}.
@@ -77,85 +70,46 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps) }; return ( - - { })}> - -
- + {})} panelTestId="privacy-modal" className="z-[80]"> + Privacy & Data Usage -
-
- - - - Privacy & Data Usage - + - - -
-
-
- setAgreed(e.target.checked)} - className="h-4 w-4 rounded border-gray-300 text-accent focus:ring-accent bg-base" - /> -
-
- {' '} - - Privacy Policy - -
-
- -
- -
-
-
-
+
+
+
+ setAgreed(e.target.checked)} + className="h-4 w-4 rounded border-line text-accent focus:ring-accent-line bg-surface" + /> +
+
+ {' '} + + Privacy Policy +
-
-
+ +
+ +
+
+ ); } @@ -183,67 +137,28 @@ export function showPrivacyModal(): void { }; return ( - { root.unmount(); container.remove(); }} > - - Privacy & Data Usage + + + +
+ -
- -
-
-
- - + Close + +
+ ); }; diff --git a/src/components/ProgressCard.tsx b/src/components/ProgressCard.tsx index a3543a4..f8341d0 100644 --- a/src/components/ProgressCard.tsx +++ b/src/components/ProgressCard.tsx @@ -1,4 +1,4 @@ -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button } from '@/components/ui'; interface ProgressCardProps { progress: number; @@ -32,7 +32,7 @@ export function ProgressCard({ const operationLabel = getOperationLabel(); return ( -
+
{/* Header with operation type and cancel button */}
@@ -52,29 +52,27 @@ export function ProgressCard({
)}
- +
{/* Progress bar */}
{/* Stats row */} -
+
{completedChapters !== undefined && ( <> {completedChapters} chapters diff --git a/src/components/ProgressPopup.tsx b/src/components/ProgressPopup.tsx index 4e0c7db..57afa29 100644 --- a/src/components/ProgressPopup.tsx +++ b/src/components/ProgressPopup.tsx @@ -32,17 +32,17 @@ export function ProgressPopup({
+
+
+
+
+
+
+ ); +} + +function ThemeChoice({ + label, + colors, + selected, + onClick, + className, +}: { + label: string; + colors: ThemeColorSet; + selected: boolean; + onClick: () => void; + className?: string; +}) { + return ( + + {selected ? ( + + ) : ( + + )} + + {label} + + + + ); +} + type SectionId = 'api' | 'theme' | 'docs' | 'account' | 'admin'; type SidebarSection = { @@ -133,20 +178,37 @@ type AdminSubTab = 'providers' | 'features'; export function SettingsTrigger({ className = '', triggerLabel, + variant = 'button', onOpen, }: { className?: string; triggerLabel?: string; + variant?: 'button' | 'sidebar'; onOpen: () => void; }) { + if (variant === 'sidebar') { + return ( + } + label={triggerLabel ?? 'Settings'} + /> + ); + } + return ( ); @@ -483,8 +545,8 @@ export function SettingsModal({ setActiveSection(visibleSections[0]?.id ?? 'theme'); }, [activeSection, visibleSections]); - const fieldLabelClass = 'block text-[11px] font-semibold uppercase tracking-wide text-muted'; - const sectionShellClass = 'space-y-2 pb-3 border-b border-offbase px-0.5'; + const fieldLabelClass = 'block text-[11px] font-semibold uppercase tracking-wide text-soft'; + const sectionShellClass = 'space-y-2 pb-3 border-b border-line-soft px-0.5'; const sectionHeadingClass = 'text-sm font-semibold text-foreground'; const effectiveProviderType = resolveEffectiveProviderType({ providerRef: selectedProviderRef, @@ -511,53 +573,32 @@ export function SettingsModal({ return ( <> - - - -
- - -
-
- - + {/* Header */} -
+
- - Settings - + Settings
@@ -572,50 +613,42 @@ export function SettingsModal({ /> ) : ( <> - {/* Mobile: 2x2 grid nav */} -
+ {/* Mobile nav */} + {visibleSections.map((section) => { const Icon = section.icon; return ( - + active={activeSection === section.id} + icon={} + label={section.label} + /> ); })} -
+
{/* Desktop: vertical sidebar */} - {/* Content */} @@ -630,7 +663,7 @@ export function SettingsModal({
{ttsProviders.length === 0 ? ( -

+

User API keys are restricted and no shared provider is configured. Ask an admin to add one.

) : ( @@ -656,28 +689,26 @@ export function SettingsModal({ setCustomModelInput(''); }} > - + {selectedProviderOption?.name || 'Select Provider'} - + - + - {ttsProviders.map((provider) => ( - listboxOptionClass(active)} value={provider} > {({ selected }) => ( @@ -692,15 +723,15 @@ export function SettingsModal({ )} )} - + ))} - + )}
{restrictUserApiKeys && ( -

+

This instance restricts user API keys. TTS runs through admin-configured shared providers only.

)} @@ -737,7 +768,7 @@ export function SettingsModal({
)} {isSharedSelected && ( -

+

This is a shared provider configured by an admin. API key and base URL are managed server-side.

)} @@ -745,7 +776,7 @@ export function SettingsModal({
{!showAllProviderModels && ( -

+

This instance restricts model selection to each provider's default model.

)} @@ -761,14 +792,14 @@ export function SettingsModal({ } }} > - + {selectedModel ? ( {selectedModel.name} {selectedModelVersion && ( - + {selectedModelVersion} )} @@ -777,23 +808,21 @@ export function SettingsModal({ Select Model )} - + - + - {ttsModels.map((model) => ( - listboxOptionClass(active)} value={model} > {({ selected }) => ( @@ -801,7 +830,7 @@ export function SettingsModal({ {model.name} {model.id.includes(':') && ( - + {model.id.slice(model.id.indexOf(':'))} )} @@ -813,9 +842,9 @@ export function SettingsModal({ )} )} - + ))} - + @@ -849,7 +878,8 @@ export function SettingsModal({
+ className="w-full" + /> ); })()}
{/* Custom theme */}
- + {(() => { const colors = getThemeColors('custom'); const isActive = theme === 'custom'; return (
- - +
{isCustomExpanded && ( @@ -1043,41 +1034,19 @@ export function SettingsModal({ {/* Light themes */}
- +
{lightThemes.map((t) => { const colors = getThemeColors(t.id); const isActive = theme === t.id; return ( - + /> ); })}
@@ -1085,41 +1054,19 @@ export function SettingsModal({ {/* Dark themes */}
- +
{darkThemes.map((t) => { const colors = getThemeColors(t.id); const isActive = theme === t.id; return ( - + /> ); })}
@@ -1135,7 +1082,8 @@ export function SettingsModal({ @@ -1147,14 +1095,16 @@ export function SettingsModal({ @@ -1166,30 +1116,16 @@ export function SettingsModal({ {/* Admin Section */} {activeSection === 'admin' && isAdmin && (
-
- {([ - { id: 'providers', label: 'Shared providers' }, - { id: 'features', label: 'Site features' }, - ] as { id: AdminSubTab; label: string }[]).map((tab) => { - const active = adminSubTab === tab.id; - return ( - - ); - })} -
+ {adminSubTab === 'providers' && } {adminSubTab === 'features' && }
@@ -1199,10 +1135,10 @@ export function SettingsModal({ {activeSection === 'account' && (
{/* Session info */} -
+

Current Session

-

Logged in as:

+

Logged in as:

{session?.user ? ( <>

@@ -1211,7 +1147,7 @@ export function SettingsModal({ : (session.user.name || session.user.email || 'Account')}

{!session.user.isAnonymous && ( -

{session.user.email}

+

{session.user.email}

)} {session.user.isAnonymous && (

Anonymous session

@@ -1225,20 +1161,20 @@ export function SettingsModal({ {/* Export Data */} {session?.user && ( - + )} {/* Actions */} @@ -1247,27 +1183,31 @@ export function SettingsModal({ <> -
- - -

- Permanently deletes your account and all data. -

-
+ {enableDestructiveDelete && ( +
+ + +

+ Permanently deletes your account and all data. +

+
+ )} ) : ( -
-

+

+

{session?.user?.isAnonymous ? (runtimeConfig.enableUserSignups ? '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({

- {runtimeConfig.enableUserSignups && ( - )} - @@ -1304,12 +1244,7 @@ export function SettingsModal({
)} - - -
-
-
-
+ -
- +

Changelog

-

+

{normalizedAppVersion ? `Current version: v${normalizedAppVersion}` : 'Release history from GitHub'} @@ -1459,20 +1393,20 @@ function SettingsChangelogPanel({

{loading && ( -
+
Loading changelog…
)} {!loading && error && ( -
+

Could not load changelog right now.

-

{error}

+

{error}

Open GitHub Releases @@ -1480,7 +1414,7 @@ function SettingsChangelogPanel({ )} {!loading && !error && manifest.length === 0 && ( -
+
No releases found.
)} @@ -1493,35 +1427,35 @@ function SettingsChangelogPanel({ const normalizedName = normalizeVersion(entry.name || ''); const showName = Boolean(entry.name) && normalizedName !== normalizedTag; return ( -
+
@@ -654,14 +655,16 @@ function SourceBadge({ return (
{canReset && !dirty && ( - + )} {dirty ? ( Modified diff --git a/src/components/admin/AdminProvidersPanel.tsx b/src/components/admin/AdminProvidersPanel.tsx index 1e5ff5c..55dfc0f 100644 --- a/src/components/admin/AdminProvidersPanel.tsx +++ b/src/components/admin/AdminProvidersPanel.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; @@ -14,11 +14,15 @@ import { Section, ToggleRow, inputClass, - listboxButtonClass, - listboxOptionClass, - listboxOptionsClass, -} from '@/components/formPrimitives'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; + SharedListboxButton, + SharedListboxOption, + SharedListboxOptions, + Button, + IconButton, + Input, + MenuItemsSurface, + MenuActionItem, +} from '@/components/ui'; type ProviderType = TtsProviderId; @@ -364,7 +368,8 @@ export function AdminProvidersPanel() { {!editingId ? ( @@ -637,7 +641,9 @@ export function AdminProvidersPanel() {
- - - {({ active }) => ( - - )} - - - {({ active, disabled }) => ( - - )} - - - {({ active }) => ( - - )} - - - {({ active }) => ( - - )} - - + startEdit(p)}> + Edit + + setDefault(p.slug)} + disabled={!p.enabled || defaultProviderSlug === p.slug} + > + Set as default + + toggleEnabled(p)}> + {p.enabled ? 'Disable' : 'Enable'} + + remove(p.id)}> + Delete + +
diff --git a/src/components/admin/ui.tsx b/src/components/admin/ui.tsx deleted file mode 100644 index fdf6fa5..0000000 --- a/src/components/admin/ui.tsx +++ /dev/null @@ -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'; diff --git a/src/components/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx index 0923087..e429b1e 100644 --- a/src/components/auth/AuthLoader.tsx +++ b/src/components/auth/AuthLoader.tsx @@ -7,7 +7,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext import { useAuthSession } from '@/hooks/useAuthSession'; import { getAuthClient } from '@/lib/client/auth-client'; import { LoadingSpinner } from '@/components/Spinner'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button } from '@/components/ui'; function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -252,25 +252,25 @@ export function AuthLoader({ children }: { children: ReactNode }) { if (isLoading) { return ( -
+
{bootstrapError ? (
-

{bootstrapError}

- +
) : ( -

+

{isAutoLoggingIn ? 'Starting anonymous session...' : 'Loading...'}

)} diff --git a/src/components/auth/ClaimDataModal.tsx b/src/components/auth/ClaimDataModal.tsx index c463bdd..54f80ec 100644 --- a/src/components/auth/ClaimDataModal.tsx +++ b/src/components/auth/ClaimDataModal.tsx @@ -1,16 +1,9 @@ 'use client'; -import { Fragment, useState } from 'react'; -import { - Dialog, - DialogPanel, - DialogTitle, - Transition, - TransitionChild, - Button, -} from '@headlessui/react'; +import { useState } from 'react'; import { useRouter } from 'next/navigation'; import toast from 'react-hot-toast'; +import { Button, ModalFrame, ModalTitle } from '@/components/ui'; export type ClaimableCounts = { documents: number; @@ -74,91 +67,48 @@ export default function ClaimDataModal({ }; return ( - - - + Existing Data Found + +

+ We found existing anonymous data from before auth was enabled. + Claim it now to attach it to your account. +

+ +
+
Claimable data
+
    +
  • {claimableCounts.documents} document(s)
  • +
  • {claimableCounts.audiobooks} audiobook(s)
  • +
  • {claimableCounts.preferences} preference set(s)
  • +
  • {claimableCounts.progress} reading progress record(s)
  • +
+
+ +

+ ⚠️ First user to claim this data will own it and revoke access for anyone else. +

+ +
+ - -
- -
-
-
- - + Dismiss + + +
+ ); } diff --git a/src/components/auth/RateLimitBanner.tsx b/src/components/auth/RateLimitBanner.tsx index 21a44f8..58975a1 100644 --- a/src/components/auth/RateLimitBanner.tsx +++ b/src/components/auth/RateLimitBanner.tsx @@ -19,13 +19,13 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) { const isAnonymous = status.userType === 'anonymous'; return ( -
+
- + Daily TTS limit reached. - + {`Used ${formatCharCount(status.currentCount)} / ${formatCharCount(status.limit)} characters.`} {' Resets in '}{timeUntilReset}. @@ -36,7 +36,7 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) { href="/signup" className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-md 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 @@ -64,7 +64,7 @@ export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) { if (isAtLimit) { return ( - + Limit reached ); @@ -72,7 +72,7 @@ export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) { if (isWarning) { return ( - + {formatCharCount(status.remainingChars)} chars left ); diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index a30d419..d9e44ea 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -1,6 +1,5 @@ 'use client'; -import { Button } from '@headlessui/react'; import Link from 'next/link'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; @@ -8,6 +7,7 @@ import { useAuthSession } from '@/hooks/useAuthSession'; import { getAuthClient } from '@/lib/client/auth-client'; import { useRouter } from 'next/navigation'; import { UserIcon } from '@/components/icons/Icons'; +import { ButtonLink, IconButton, SidebarNavItem, SidebarNavLink } from '@/components/ui'; type UserMenuVariant = 'toolbar' | 'sidebar'; @@ -31,21 +31,24 @@ export function UserMenu({ 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 (variant === 'sidebar') { return (
- - - Connect + + } + label="Connect" + /> {enableUserSignups && ( - - - Create account + + } + label="Create account" + /> )}
@@ -54,17 +57,13 @@ export function UserMenu({ return (
- - - + + Connect + {enableUserSignups && ( - - - + + Create account + )}
); @@ -72,40 +71,44 @@ export function UserMenu({ if (variant === 'sidebar') { return ( - + icon={} + label={session.user.email || 'Account'} + trailing={( + + + + + + )} + /> ); } return ( -
- +
+ {session.user.email || 'Account'} - +
); } diff --git a/src/components/doclist/CreateFolderDialog.tsx b/src/components/doclist/CreateFolderDialog.tsx index 69385b5..2e161c3 100644 --- a/src/components/doclist/CreateFolderDialog.tsx +++ b/src/components/doclist/CreateFolderDialog.tsx @@ -1,5 +1,5 @@ -import { Fragment, KeyboardEvent } from 'react'; -import { Dialog, DialogPanel, DialogTitle, Input, Transition, TransitionChild } from '@headlessui/react'; +import { KeyboardEvent } from 'react'; +import { Input, ModalFrame, ModalTitle } from '@/components/ui'; interface CreateFolderDialogProps { isOpen: boolean; @@ -17,52 +17,20 @@ export function CreateFolderDialog({ onClose, }: CreateFolderDialogProps) { return ( - - - -
- - -
-
- - - - Create New Folder - -
- 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 - /> -

Press Enter to create or Escape to cancel

-
-
-
-
-
-
-
+ + Create New Folder +
+ onFolderNameChange(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Enter folder name" + controlSize="lg" + autoFocus + /> +

Press Enter to create or Escape to cancel

+
+
); -} \ No newline at end of file +} diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 77dfd36..b854554 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -22,6 +22,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'; import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog'; import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton'; import { DocumentUploader, type UploadBatchState } from '@/components/documents/DocumentUploader'; +import { IconButton } from '@/components/ui'; import { DocumentDndProvider } from './dnd/DocumentDndProvider'; import { DocumentSelectionProvider, @@ -139,14 +140,14 @@ function SidebarUploadLoader({ const dashOffset = circumference - (progress / 100) * circumference; return ( -
+
{label} - {completedFiles}/{totalFiles} + {completedFiles}/{totalFiles}
- {progress}% + {progress}%
{currentFileName && ( -

+

{currentFileName}

)} @@ -182,6 +183,16 @@ function SidebarUploadLoader({ ); } +function DocumentListStateLoader() { + return ( +
+ ); +} + function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const cachedState = cachedDocumentListState; const [sortBy, setSortBy] = useState(cachedState?.sortBy ?? DEFAULT_STATE.sortBy); @@ -649,28 +660,32 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { }} > {!isLoading && showHint && allDocuments.length > 1 && ( -
-
+
+

Drag files onto each other to make folders. Drop into the sidebar to move.

- +
)} {isLoading ? (
- + {isInitialized ? ( + + ) : ( + + )}
) : allDocuments.length === 0 ? (
diff --git a/src/components/doclist/DocumentListSkeleton.tsx b/src/components/doclist/DocumentListSkeleton.tsx index e717f40..b1a6708 100644 --- a/src/components/doclist/DocumentListSkeleton.tsx +++ b/src/components/doclist/DocumentListSkeleton.tsx @@ -15,11 +15,11 @@ function IconsSkeleton({ iconSize }: { iconSize: IconSize }) {
{Array.from({ length: ICON_SKELETON_ITEM_COUNT }).map((_, index) => ( -
-
+
+
-
-
+
+
))} @@ -31,18 +31,18 @@ function IconsSkeleton({ iconSize }: { iconSize: IconSize }) { function ListSkeleton() { return (
-
+
-
+
-
+
-
+
-
+
@@ -50,23 +50,23 @@ function ListSkeleton() { {Array.from({ length: 10 }).map((_, index) => (
-
-
+
+
-
+
-
+
-
+
-
+
))} @@ -78,24 +78,24 @@ function ListSkeleton() { function GallerySkeleton() { return (
-
+
-
-
-
+
+
+
-
-
+
+
-
+
{Array.from({ length: 10 }).map((_, index) => ( -
-
+
+
-
+
))} diff --git a/src/components/doclist/DocumentPreview.tsx b/src/components/doclist/DocumentPreview.tsx index 7f23165..2d0fc08 100644 --- a/src/components/doclist/DocumentPreview.tsx +++ b/src/components/doclist/DocumentPreview.tsx @@ -243,7 +243,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { return (
{imagePreview ? ( <> @@ -261,7 +261,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { ref={imageRef} src={imagePreview} 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} loading="lazy" onLoad={() => { @@ -285,16 +285,16 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { ) : textPreview ? ( <> -
+
-
+
{isTxtFile ? ( -
+                
                   {textPreview}
                 
) : ( -
+
{children}, img: () => null, blockquote: (props) => ( -
+
), code: (props) => ( ), diff --git a/src/components/doclist/views/DocumentTile.tsx b/src/components/doclist/views/DocumentTile.tsx index 467847b..e74cabc 100644 --- a/src/components/doclist/views/DocumentTile.tsx +++ b/src/components/doclist/views/DocumentTile.tsx @@ -2,10 +2,10 @@ import Link from 'next/link'; import { useDrag, useDrop, type DragSourceMonitor } from 'react-dnd'; -import { Button } from '@headlessui/react'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import type { DocumentListDocument, IconSize } from '@/types/documents'; import { DocumentPreview } from '@/components/doclist/DocumentPreview'; +import { IconButton } from '@/components/ui'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; @@ -138,11 +138,11 @@ export function DocumentTile({ data-doc-tile aria-selected={isSelected} 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 - ? 'border-accent bg-offbase' - : 'border-offbase bg-base hover:bg-offbase hover:border-accent') + - (isDropTarget ? ' ring-1 ring-accent' : '') + + ? 'border-accent-line bg-surface-sunken' + : 'border-line bg-surface hover:bg-accent-wash hover:border-accent-line') + + (isDropTarget ? ' ring-1 ring-accent-line' : '') + (isDragging ? ' opacity-50' : '') } > @@ -166,11 +166,11 @@ export function DocumentTile({ > {doc.type === 'pdf' ? ( - + ) : doc.type === 'epub' ? ( - + ) : ( - + )} {showDeleteButton && ( - + )}
diff --git a/src/components/doclist/views/GalleryView.tsx b/src/components/doclist/views/GalleryView.tsx index ed6d1bf..a047b63 100644 --- a/src/components/doclist/views/GalleryView.tsx +++ b/src/components/doclist/views/GalleryView.tsx @@ -1,13 +1,12 @@ 'use client'; -import Link from 'next/link'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useDrag, useDrop } from 'react-dnd'; import type { DocumentListDocument } from '@/types/documents'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { formatDocumentSize } from '@/components/doclist/formatSize'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button, ButtonLink } from '@/components/ui'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; 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 }) { - if (doc.type === 'pdf') return ; - if (doc.type === 'epub') return ; - return ; + if (doc.type === 'pdf') return ; + if (doc.type === 'epub') return ; + return ; } function GalleryThumb({ @@ -92,34 +91,34 @@ function GalleryThumb({ onClick={onClick} aria-current={active ? 'true' : undefined} 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 - ? 'border-accent shadow-[0_10px_24px_-18px_rgba(0,0,0,0.85)] -translate-y-0.5' - : 'border-offbase hover:border-accent hover:-translate-y-0.5') + - (isOver && canDrop ? ' border-accent' : '') + + ? 'border-accent-line shadow-elev-2 -translate-y-px' + : 'border-line hover:border-accent-line hover:-translate-y-px hover:shadow-elev-2') + + (isOver && canDrop ? ' border-accent-line' : '') + (isDragging ? ' opacity-50' : '') } title={doc.name} > -
+
@@ -181,51 +180,48 @@ export function GalleryView({ return (
-
+
{activeDoc ? (
-
+

{activeDoc.name}

-

+

{activeDoc.type.toUpperCase()} • {formatDocumentSize(activeDoc.size)}

- + Open - - +
-
-
Type
+
+
Type
{activeDoc.type}
-
Size
+
Size
{formatDocumentSize(activeDoc.size)}
-
Last opened
+
Last opened
{formatDateTime(activeDoc.recentlyOpenedAt)}
-
Last modified
+
Last modified
{formatDateTime(activeDoc.lastModified)}
{activeDoc.folderId && ( <> -
Folder
+
Folder
{folderNameById?.[activeDoc.folderId] ?? activeDoc.folderId}
@@ -233,19 +229,19 @@ export function GalleryView({ )} {activeDoc.type === 'pdf' && ( <> -
Parse status
+
Parse status
{formatParseStatus(activeDoc.parseStatus)}
)}
) : ( -

No documents to show

+

No documents to show

)}
-
+
; - if (doc.type === 'epub') return ; - return ; + if (doc.type === 'pdf') return ; + if (doc.type === 'epub') return ; + return ; } function HeaderCell({ @@ -62,8 +62,8 @@ function HeaderCell({ onSortChange(field, nextDir); }} 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 ' + - (active ? 'text-accent' : 'text-muted') + + '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-soft') + (align === 'right' ? ' justify-end' : '') + ' ' + (className ?? '') @@ -131,11 +131,11 @@ function DocRow({ data-doc-tile aria-selected={isSelected} 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 - ? 'bg-offbase text-accent' - : 'text-foreground hover:bg-offbase') + - (isTarget ? ' ring-1 ring-accent ring-inset' : '') + + ? 'bg-surface-sunken text-accent' + : 'text-foreground hover:bg-accent-wash') + + (isTarget ? ' ring-1 ring-accent-line ring-inset' : '') + (isDragging ? ' opacity-50' : '') } > @@ -149,19 +149,19 @@ function DocRow({ {doc.name} - {doc.type} - + {doc.type} + {formatDocumentSize(doc.size)} - + {formatDate(doc.lastModified)} - +
); } @@ -198,7 +198,7 @@ export function ListView({ return (
-
+
diff --git a/src/components/doclist/window/FinderSidebar.tsx b/src/components/doclist/window/FinderSidebar.tsx index 0ecf12c..20ee1c3 100644 --- a/src/components/doclist/window/FinderSidebar.tsx +++ b/src/components/doclist/window/FinderSidebar.tsx @@ -1,12 +1,13 @@ 'use client'; -import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'; +import { Menu, MenuButton, Transition } from '@headlessui/react'; import { Fragment, useRef, type CSSProperties, type ReactNode } from 'react'; import { useDrop } from 'react-dnd'; import type { Folder, SidebarFilter } from '@/types/documents'; import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons'; import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons'; import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; +import { IconButton, MenuActionItem, MenuItemsSurface, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui'; interface FinderSidebarProps { filter: SidebarFilter; @@ -30,60 +31,6 @@ interface FinderSidebarProps { const MIN_WIDTH = 168; const MAX_WIDTH = 320; -interface SidebarRowProps { - active: boolean; - onClick: () => void; - icon: ReactNode; - label: string; - count?: number; - countClassName?: string; - trailing?: ReactNode; - isDropTarget?: boolean; -} - -function SidebarRow({ - active, - onClick, - icon, - label, - count, - countClassName, - trailing, - isDropTarget, -}: SidebarRowProps) { - return ( - - ); -} - function FolderRow({ folder, active, @@ -122,7 +69,8 @@ function FolderRow({ ref={dropRef as unknown as React.RefObject} className="group/folder relative" > - } @@ -131,42 +79,20 @@ function FolderRow({ countClassName="group-hover/folder:-translate-x-6 group-focus-within/folder:-translate-x-6" isDropTarget={isDropTarget} /> - -
- ); -} - -function SectionHeader({ - children, - isFirst, - rightSlot, -}: { - children: ReactNode; - isFirst?: boolean; - rightSlot?: ReactNode; -}) { - return ( -
- {children} - {rightSlot && {rightSlot}} +
); } @@ -204,19 +130,20 @@ export function FinderSidebar({ }; return ( - + ); } diff --git a/src/components/doclist/window/FinderStatusBar.tsx b/src/components/doclist/window/FinderStatusBar.tsx index acbd573..2e9c894 100644 --- a/src/components/doclist/window/FinderStatusBar.tsx +++ b/src/components/doclist/window/FinderStatusBar.tsx @@ -19,14 +19,14 @@ export function FinderStatusBar({
{summary} {selectedCount > 0 ? `${selectedCount} of ${itemCount} selected` : `${itemCount} item${itemCount === 1 ? '' : 's'}`} - + {formatDocumentSize(totalSize)}
diff --git a/src/components/doclist/window/FinderToolbar.tsx b/src/components/doclist/window/FinderToolbar.tsx index 4e57f18..e403754 100644 --- a/src/components/doclist/window/FinderToolbar.tsx +++ b/src/components/doclist/window/FinderToolbar.tsx @@ -1,6 +1,6 @@ '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 { IconsViewIcon, @@ -10,6 +10,7 @@ import { HamburgerIcon, } from './finderIcons'; import { ChevronUpDownIcon } from '@/components/icons/Icons'; +import { SearchField, SharedListboxButton, SharedListboxOption, SharedListboxOptions, Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment } from '@/components/ui'; import type { ReactNode } from 'react'; interface FinderToolbarProps { @@ -52,22 +53,6 @@ const ICON_SIZES: Array<{ value: IconSize; label: string }> = [ { value: 'xl', label: 'XL' }, ]; -// Match SettingsModal / UserMenu trigger sizing exactly so all bar buttons share one rhythm. -const TOOLBAR_BTN = - 'inline-flex items-center py-1 px-2 rounded-md border bg-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({ viewMode, onViewModeChange, @@ -89,26 +74,25 @@ export function FinderToolbar({ const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc; return ( -
-
+ {leftSlot && ( -
+
{leftSlot}
)} - + -
+ {VIEW_BUTTONS.map(({ value, label, Icon }) => { const active = viewMode === value; const isIconsToggle = value === 'icons'; @@ -117,110 +101,84 @@ export function FinderToolbar({ key={value} className={isIconsToggle ? 'relative group/icons inline-flex items-center' : 'inline-flex items-center'} > - + {isIconsToggle && viewMode === 'icons' && (
-
+ {ICON_SIZES.map(({ value: sizeValue, label: sizeLabel }) => { const sizeActive = iconSize === sizeValue; return ( - + ); })} -
+
)}
); })} -
+ {showSortControls && (
- + - + {currentSort.label} - - + + {SORT_OPTIONS.map((opt) => ( - - `cursor-pointer select-none rounded-sm py-1.5 px-2.5 text-xs ${ - active ? 'bg-offbase text-accent' : 'text-foreground' - } ${selected ? 'font-semibold' : ''}` - } + tone="compact" > {opt.label} - + ))} - +
)}
-
- - onQueryChange(e.target.value)} - placeholder="Search" - className="flex-1 min-w-0 bg-transparent outline-none text-xs text-foreground placeholder:text-muted" - /> -
+ onQueryChange(e.target.value)} + placeholder="Search" + className="hidden w-[160px] md:w-[200px] sm:flex" + icon={} + /> {rightSlot && ( -
+
{rightSlot}
)} -
-
+
); } diff --git a/src/components/doclist/window/FinderWindow.tsx b/src/components/doclist/window/FinderWindow.tsx index be5f157..2c10050 100644 --- a/src/components/doclist/window/FinderWindow.tsx +++ b/src/components/doclist/window/FinderWindow.tsx @@ -1,7 +1,7 @@ 'use client'; -import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'; -import { Fragment, useEffect, useState, type ReactNode } from 'react'; +import type { ReactNode } from 'react'; +import { LibraryFrame, useIsNarrow } from '@/components/layout'; interface FinderWindowProps { toolbar: ReactNode; @@ -14,22 +14,7 @@ interface FinderWindowProps { onRequestSidebarClose?: () => void; } -const NARROW_QUERY = '(max-width: 767px)'; - -export function useIsNarrow(): boolean { - const [isNarrow, setIsNarrow] = useState(() => - typeof window !== 'undefined' ? window.matchMedia(NARROW_QUERY).matches : false, - ); - useEffect(() => { - if (typeof window === 'undefined') return; - const mq = window.matchMedia(NARROW_QUERY); - const update = () => setIsNarrow(mq.matches); - update(); - mq.addEventListener('change', update); - return () => mq.removeEventListener('change', update); - }, []); - return isNarrow; -} +export { useIsNarrow }; /** * Finder-style file pane: sidebar + toolbar + content + status bar. @@ -43,60 +28,15 @@ export function FinderWindow({ sidebarOpen, onRequestSidebarClose, }: FinderWindowProps) { - const isNarrow = useIsNarrow(); - return ( -
- {toolbar} - -
- {!isNarrow && sidebarOpen && ( -
{sidebar}
- )} - -
- {children} -
-
- - {statusBar} - - {/* Mobile drawer */} - - undefined)} - className="relative z-40 md:hidden" - > - -
- -
- - - {sidebar} - - -
-
-
-
+ + {children} + ); } diff --git a/src/components/documents/DexieMigrationModal.tsx b/src/components/documents/DexieMigrationModal.tsx index be7bd30..df469d1 100644 --- a/src/components/documents/DexieMigrationModal.tsx +++ b/src/components/documents/DexieMigrationModal.tsx @@ -1,12 +1,12 @@ 'use client'; -import { Fragment, useCallback, useEffect, useState } from 'react'; -import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Button } from '@headlessui/react'; +import { useCallback, useEffect, useState } from 'react'; import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie'; import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; import { useDocuments } from '@/contexts/DocumentContext'; import type { BaseDocument } from '@/types/documents'; import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents'; +import { Button, ModalFrame, ModalTitle } from '@/components/ui'; type DexieMigrationModalProps = { isOpen: boolean; @@ -133,87 +133,54 @@ export function DexieMigrationModal({ } }, [loadLocalDexieDocs, onComplete, refreshDocuments]); - if (!isOpen) return null; - return ( - - (closeDisabled ? null : onComplete())}> - -
- - -
-
- - - - {title} - -
-

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

- {isUploading && ( -
-

{status}

-
-
-
-
- )} - {!isUploading && status ?

{status}

: null} -
- -
- - -
- - + { + if (!closeDisabled) onComplete(); + }} + panelTestId="migration-modal" + className="z-[80]" + > + {title} +
+

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

+ {isUploading && ( +
+

{status}

+
+
+
-
-
-
+ )} + {!isUploading && status ?

{status}

: null} +
+ +
+ + +
+ ); } diff --git a/src/components/documents/DocumentHeaderMenu.tsx b/src/components/documents/DocumentHeaderMenu.tsx index fc12776..b44a479 100644 --- a/src/components/documents/DocumentHeaderMenu.tsx +++ b/src/components/documents/DocumentHeaderMenu.tsx @@ -1,10 +1,11 @@ 'use client'; -import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'; +import { Menu, MenuButton, Transition } from '@headlessui/react'; import { Fragment } from 'react'; import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons'; import { ZoomControl } from '@/components/documents/ZoomControl'; import { UserMenu } from '@/components/auth/UserMenu'; +import { IconButton, MenuActionItem, MenuItemsSurface, ToolbarButton } from '@/components/ui'; interface DocumentHeaderMenuProps { zoomLevel: number; @@ -47,45 +48,33 @@ export function DocumentHeaderMenu({ max={maxZoom} /> {onOpenSegments && ( - + + )} {showAudiobookExport && onOpenAudiobook && ( - + + )} - + +
); @@ -95,35 +84,30 @@ export function DocumentHeaderMenu({
- + - + {/* Zoom Controls Section */}
-

Zoom / Padding

+

Zoom / Padding

{ - // 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(); - }} + onIncrease={onZoomIncrease} onDecrease={onZoomDecrease} min={minZoom} max={maxZoom} @@ -134,52 +118,28 @@ export function DocumentHeaderMenu({ {/* Actions Section */}
{onOpenSegments && ( - - {({ active }) => ( - - )} - + + + {isSegmentsOpen ? 'Hide Segments' : 'Segments'} + )} {showAudiobookExport && onOpenAudiobook && ( - - {({ active }) => ( - - )} - + + + {isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'} + )} - - {({ active }) => ( - - )} - + + + {isSettingsOpen ? 'Hide Settings' : 'Settings'} +
{/* Auth Section */} -
+
- +
diff --git a/src/components/documents/DocumentSelectionModal.tsx b/src/components/documents/DocumentSelectionModal.tsx index 9d6faa4..a89b152 100644 --- a/src/components/documents/DocumentSelectionModal.tsx +++ b/src/components/documents/DocumentSelectionModal.tsx @@ -1,9 +1,8 @@ 'use client'; -import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; -import { Fragment, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { BaseDocument } from '@/types/documents'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { Button, ModalFrame, ModalTitle } from '@/components/ui'; interface DocumentSelectionModalProps { isOpen: boolean; @@ -114,120 +113,83 @@ export function DocumentSelectionModal({ }; return ( - - - -
- - -
-
- - - - {title} - {files.length > 0 && ( -
- -
- )} -
- -
- {isLoading ? ( - - ) : errorMessage ? ( -
{errorMessage}
- ) : files.length === 0 ? ( -
No documents found.
- ) : ( -
- {files.map((file) => { - const isSelected = selectedIds.has(file.id); - return ( -
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'} - `} - > -
e.stopPropagation()}> - handleCheckboxChange(file.id, e.target.checked)} - className="rounded border-muted text-accent focus:ring-accent" - /> -
-
- {file.name} -
-
{formatSize(file.size)}
-
- ); - })} -
- )} -
- -
- - -
-
-
+ + + {title} + {files.length > 0 && ( +
+
-
-
-
+ )} + + +
+ {isLoading ? ( + + ) : errorMessage ? ( +
{errorMessage}
+ ) : files.length === 0 ? ( +
No documents found.
+ ) : ( +
+ {files.map((file) => { + const isSelected = selectedIds.has(file.id); + return ( +
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' + }`} + > +
e.stopPropagation()}> + handleCheckboxChange(file.id, e.target.checked)} + className="rounded border-muted text-accent focus:ring-accent-line" + /> +
+
+ {file.name} +
+
{formatSize(file.size)}
+
+ ); + })} +
+ )} +
+ +
+ + +
+ ); } @@ -237,9 +199,9 @@ function DocumentSelectionSkeleton() {
{rows.map((_, index) => (
-
-
-
+
+
+
))}
diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx index 4dd1f9b..07442c2 100644 --- a/src/components/documents/DocumentSettings.tsx +++ b/src/components/documents/DocumentSettings.tsx @@ -15,8 +15,7 @@ import { clampSegmentPreloadSentenceLookahead, clampTtsSegmentMaxBlockLength, } from '@/types/config'; -import { Section, ToggleRow, CheckItem, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { IconButton, RangeInput, Section, ToggleRow, CheckItem, SegmentedControl } from '@/components/ui'; import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons'; import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf'; import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse'; @@ -51,8 +50,6 @@ const viewTypeTextMapping = [ { 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 = { label: string; value: number; @@ -80,14 +77,13 @@ function RangeSetting({
- onChange(Number(event.target.value))} - className={`flex-1 ${rangeInputClassName}`} + className="flex-1" /> {formatter(value)}
@@ -163,27 +159,13 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { >
-
- {viewTypeTextMapping.map((view) => { - const active = selectedView.id === view.id; - return ( - - ); - })} -
+ ({ value: view.id as ViewType, label: view.name }))} + onChange={(nextViewType) => updateConfigKey('viewType', nextViewType)} + ariaLabel="Page mode" + className="grid-cols-3" + /> {selectedView.id === 'scroll' ? (

Scroll mode may be slower on large PDFs.

) : null} @@ -214,20 +196,20 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { action={
- + PP-DocLayout-V3 {pdf.parseStatus ?? 'pending'} - +
} @@ -240,7 +222,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: { disabled={pdf.parseStatus !== 'ready'} variant="flat" /> -
+
Skip Block Kinds While Reading Aloud diff --git a/src/components/documents/DocumentUploader.tsx b/src/components/documents/DocumentUploader.tsx index c5ce9fe..4f1a07c 100644 --- a/src/components/documents/DocumentUploader.tsx +++ b/src/components/documents/DocumentUploader.tsx @@ -6,6 +6,7 @@ import { UploadIcon } from '@/components/icons/Icons'; import { useDocuments } from '@/contexts/DocumentContext'; import { uploadDocxAsPdf } from '@/lib/client/api/documents'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; +import { dropzoneSurfaceClass } from '@/components/ui'; interface DocumentUploaderProps { className?: string; @@ -155,26 +156,7 @@ export function DocumentUploader({ noKeyboard: variant === 'overlay' }); - const containerBase = `group w-full rounded transform transition-all duration-200 ease-in-out ${ - 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'; + const isDisabled = isUploading || isConverting; if (variant === 'overlay') { const rootProps = getRootProps(); @@ -183,19 +165,19 @@ export function DocumentUploader({ {children} {isDragActive && ( -
-
- +
+
+

Drop files here to upload

-

+

{enableDocx ? 'Accepts PDF, EPUB, TXT, MD, or DOCX' : 'Accepts PDF, EPUB, TXT, or MD'}

{error && ( -

+

Upload failed: {error} — try again.

)} @@ -203,7 +185,7 @@ export function DocumentUploader({
)} {!isDragActive && error && ( -
+
Upload failed: {error} — try again.
)} @@ -214,12 +196,17 @@ export function DocumentUploader({ return (
{variant === 'compact' ? (
- + {isUploading ? (

Uploading…

) : isConverting ? ( @@ -229,13 +216,13 @@ export function DocumentUploader({

{isDragActive ? 'Drop files here' : 'Upload documents'}

- {error &&

{error}

} + {error &&

{error}

}
)}
) : (
- + {isUploading ? (

Uploading file...

) : isConverting ? ( @@ -245,10 +232,10 @@ export function DocumentUploader({

{isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'}

-

+

{enableDocx ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}

- {error &&

{error}

} + {error &&

{error}

} )}
diff --git a/src/components/documents/ZoomControl.tsx b/src/components/documents/ZoomControl.tsx index b0d6a04..5a65840 100644 --- a/src/components/documents/ZoomControl.tsx +++ b/src/components/documents/ZoomControl.tsx @@ -1,4 +1,4 @@ -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { IconButton } from '@/components/ui'; export function ZoomControl({ value, @@ -15,33 +15,25 @@ export function ZoomControl({ }) { return (
- - {value}% - +
); } diff --git a/src/components/formPrimitives.tsx b/src/components/formPrimitives.tsx deleted file mode 100644 index 687fb7a..0000000 --- a/src/components/formPrimitives.tsx +++ /dev/null @@ -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 ( -
-
-
-

{title}

- {subtitle ?

{subtitle}

: null} -
- {action ?
{action}
: null} -
- {children} -
- ); - } - - return ( -
-
-
-
-

{title}

- {subtitle ?

{subtitle}

: null} -
- {action ?
{action}
: null} -
-
-
- {children} -
-
- ); -} - -export function Card({ - children, - className = '', -}: { - children: ReactNode; - className?: string; -}) { - return ( -
- {children} -
- ); -} - -export type SwitchSize = 'sm' | 'md'; - -const SWITCH_SIZE: Record = { - 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 ( - - ); -} - -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 ( -
-
-
- {label} - {description} -
- {right ?
{right}
: null} - -
-
- ); -} - -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 ( -
- - {label} - - -
- ); -} - -export function Field({ - label, - hint, - className = '', - children, -}: { - label?: string; - hint?: string; - className?: string; - children: ReactNode; -}) { - return ( -
- {label ? : null} - {children} - {hint ?

{hint}

: null} -
- ); -} - -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 ( - - {children} - - ); -} diff --git a/src/components/icons/Icons.tsx b/src/components/icons/Icons.tsx index 74c6e8f..eef3940 100644 --- a/src/components/icons/Icons.tsx +++ b/src/components/icons/Icons.tsx @@ -246,7 +246,7 @@ export function GithubIcon(props: React.SVGProps) { export function PDFIcon(props: React.SVGProps) { return ( ) { export function EPUBIcon(props: React.SVGProps) { return ( ) { viewBox="0 0 24 24" fill="none" stroke="currentColor" - className={props.className || "w-8 h-8 text-muted"} + className={props.className || "w-8 h-8 text-soft"} {...props} > diff --git a/src/components/layout/AppHeader.tsx b/src/components/layout/AppHeader.tsx new file mode 100644 index 0000000..7edc198 --- /dev/null +++ b/src/components/layout/AppHeader.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/components/ui/cn'; + +export function AppHeader({ + left, + title, + right, + className, +}: { + left?: ReactNode; + title?: ReactNode; + right?: ReactNode; + className?: string; +}) { + return ( +
+
+
+ {left} + {typeof title === 'string' ? ( +

{title}

+ ) : ( + title + )} +
+
{right}
+
+
+ ); +} diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..f636d11 --- /dev/null +++ b/src/components/layout/AppShell.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; +import { cn } from '@/components/ui/cn'; + +export function AppShell({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +export function AppMain({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
; +} diff --git a/src/components/layout/LibraryFrame.tsx b/src/components/layout/LibraryFrame.tsx new file mode 100644 index 0000000..544c271 --- /dev/null +++ b/src/components/layout/LibraryFrame.tsx @@ -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 ( +
+ {toolbar} +
+ {!isNarrow && sidebarOpen &&
{sidebar}
} +
{children}
+
+ {statusBar} + {isNarrow && ( + + {sidebar} + + )} +
+ ); +} + +function LibrarySidebarDrawer({ + open, + onClose, + children, +}: { + open: boolean; + onClose?: () => void; + children: ReactNode; +}) { + return ( +
+ undefined)} side="left" className="w-[80vw] max-w-[280px]"> + {children} + +
+ ); +} diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts new file mode 100644 index 0000000..2c5bdf6 --- /dev/null +++ b/src/components/layout/index.ts @@ -0,0 +1,3 @@ +export * from './AppHeader'; +export * from './AppShell'; +export * from './LibraryFrame'; diff --git a/src/components/player/Navigator.tsx b/src/components/player/Navigator.tsx index dfbcceb..6a7a074 100644 --- a/src/components/player/Navigator.tsx +++ b/src/components/player/Navigator.tsx @@ -1,7 +1,8 @@ 'use client'; -import { Button, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; +import { Popover } from '@headlessui/react'; import { useState, useEffect, useRef } from 'react'; +import { IconButton, Input, PopoverSurface, PopoverTrigger } from '@/components/ui'; export const Navigator = ({ currentPage, numPages, skipToLocation }: { currentPage: number; @@ -53,36 +54,34 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: { return (
{/* Page back */} - + {/* Page number popup */} - +

{currentPage} / {numPages || 1}

-
- + +
Go to page
- -
of {numPages || 1}
+
of {numPages || 1}
-
+
{/* Page forward */} - +
); } diff --git a/src/components/player/RateLimitPauseButton.tsx b/src/components/player/RateLimitPauseButton.tsx index 3194ace..7f4a9e5 100644 --- a/src/components/player/RateLimitPauseButton.tsx +++ b/src/components/player/RateLimitPauseButton.tsx @@ -1,8 +1,8 @@ 'use client'; -import { Button } from '@headlessui/react'; import { PauseIcon } from '@/components/icons/Icons'; import { useTTS } from '@/contexts/TTSContext'; +import { IconButton } from '@/components/ui'; export function RateLimitPauseButton() { const { isPlaying, togglePlay } = useTTS(); @@ -12,14 +12,13 @@ export function RateLimitPauseButton() { if (!isPlaying) return null; return ( - + ); } diff --git a/src/components/player/SpeedControl.tsx b/src/components/player/SpeedControl.tsx index 2966a91..516d001 100644 --- a/src/components/player/SpeedControl.tsx +++ b/src/components/player/SpeedControl.tsx @@ -1,10 +1,11 @@ 'use client'; -import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; +import { Popover } from '@headlessui/react'; import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons'; import { useConfig } from '@/contexts/ConfigContext'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; +import { PopoverSurface, PopoverTrigger, RangeInput } from '@/components/ui'; export const SpeedControl = ({ setSpeedAndRestart, @@ -88,16 +89,16 @@ export const SpeedControl = ({ return ( - + {compactTriggerLabel} {triggerLabel} - - + +
{!nativeSpeedSupported && ( -
+
Native model speed is not available for this model.
)} @@ -112,8 +113,7 @@ export const SpeedControl = ({ {max.toFixed(1)}x
-
)} @@ -136,8 +135,7 @@ export const SpeedControl = ({ {max.toFixed(1)}x
-
- + ); }; diff --git a/src/components/player/TTSPlayer.tsx b/src/components/player/TTSPlayer.tsx index 2689747..94dea14 100644 --- a/src/components/player/TTSPlayer.tsx +++ b/src/components/player/TTSPlayer.tsx @@ -1,7 +1,6 @@ 'use client'; import { useTTS } from '@/contexts/TTSContext'; -import { Button } from '@headlessui/react'; import { PlayIcon, PauseIcon, @@ -12,6 +11,7 @@ import { LoadingSpinner } from '@/components/Spinner'; import { VoicesControl } from '@/components/player/VoicesControl'; import { SpeedControl } from '@/components/player/SpeedControl'; import { Navigator } from '@/components/player/Navigator'; +import { IconButton } from '@/components/ui'; export default function TTSPlayer({ currentPage, numPages }: { currentPage?: number; @@ -31,7 +31,7 @@ export default function TTSPlayer({ currentPage, numPages }: { } = useTTS(); return ( -
+
{/* Speed control */} {isProcessing ? : } - + - + - + {/* Voice control */} diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx index a94aa32..fd2cad9 100644 --- a/src/components/player/VoicesControlBase.tsx +++ b/src/components/player/VoicesControlBase.tsx @@ -2,15 +2,13 @@ import { Listbox, - ListboxButton, - ListboxOption, - ListboxOptions, } from '@headlessui/react'; import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons'; import { useEffect, useMemo, useState } from 'react'; import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro'; import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; +import { SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn } from '@/components/ui'; export function VoicesControlBase({ availableVoices, @@ -34,15 +32,16 @@ export function VoicesControlBase({ : 'bottom-full right-0 mb-1'; 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' - : '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'; + ? 'bg-surface pr-10' + : 'space-x-0.5 px-1.5 py-0.5 text-xs sm:space-x-1 sm:px-2 sm:py-1 sm:text-sm'; + const buttonTone = variant === 'field' ? 'default' : 'popover'; const iconClass = variant === 'field' ? 'h-3.5 w-3.5 shrink-0' : 'h-3 w-3 sm:h-3.5 sm:w-3.5'; 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'; const providerModelPolicy = resolveTtsProviderModelPolicy({ @@ -85,7 +84,7 @@ export function VoicesControlBase({ if (availableVoices.length === 0) { return (
-
+
No voices
@@ -121,7 +120,7 @@ export function VoicesControlBase({ } }} > - + {variant === 'field' ? ( <> @@ -139,15 +138,14 @@ export function VoicesControlBase({ )} - - + + {availableVoices.map((voiceId) => ( - - `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' : ''}` - } + inset="none" + itemClassName="flex items-center gap-2 py-1 sm:py-2" > {({ selected }) => ( <> @@ -159,13 +157,13 @@ export function VoicesControlBase({ {voiceId} )} - + ))} - + ) : ( - + {variant === 'field' ? ( <> @@ -183,20 +181,19 @@ export function VoicesControlBase({ )} - - + + {availableVoices.map((voiceId) => ( - - `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' : ''}` - } + inset="none" + itemClassName="py-1 sm:py-2" > {voiceId} - + ))} - + )}
diff --git a/src/components/reader/ReaderSidebarShell.tsx b/src/components/reader/ReaderSidebarShell.tsx index 691762b..89a5ae3 100644 --- a/src/components/reader/ReaderSidebarShell.tsx +++ b/src/components/reader/ReaderSidebarShell.tsx @@ -4,7 +4,7 @@ import { Fragment, type ReactNode } from 'react'; import { Transition } from '@headlessui/react'; import { XCircleIcon } from '@/components/icons/Icons'; import { useReaderSidebarBounds } from '@/hooks/useReaderSidebarBounds'; -import { buttonClass } from '@/components/ui/buttonPrimitives'; +import { IconButton } from '@/components/ui'; interface ReaderSidebarShellProps { isOpen: boolean; @@ -41,10 +41,10 @@ export function ReaderSidebarShell({ > @@ -58,38 +58,34 @@ export function ReaderSidebarShell({