Redesign: shared UI design system + app-wide migration (#96)
* phase 0: token foundation * phase 1: motion language * phase 2: primitives and semantic tokens * phase 3: depth and rhythm polish * phase 4: enforce design system lint rules * phase 5: split ui primitives into modules * phase 7: refactor app surfaces to ui layer * phase 9: enforce ui architecture imports * phase 10: add ui system harness * fix compact reader auth control * fix pdf loader flash * Converge sidebar and reader controls * refactor: remove initial loader state and related logic from PDFViewerPage * Remove legacy UI shim imports * Converge modal and drawer frames * Migrate secondary modals to shared frame * Move settings modal onto shared frame * Use shared cards in audiobook settings * Converge choice and popover surfaces * Converge reader navigation buttons * Refactor UI components to use consistent button and icon styles across the application * refactor(ui): unify button usage in settings, admin, and doclist components Replace native button elements with shared Button, ChoiceTile, and IconButton components for consistent UI behavior and styling. Update classNames and props to match new component APIs. Adjust FinderSidebar to use utility function for conditional class merging. * feat(ui): introduce shared Listbox components for unified select and dropdown styling Replace direct usage of Headless UI Listbox primitives with new SharedListboxButton, SharedListboxOption, and SharedListboxOptions components across AudiobookExportModal, FinderToolbar, VoicesControlBase, and select UI. Refactor related imports and classNames to centralize dropdown styling and logic. Simplify UserMenu button markup for improved consistency. This change consolidates dropdown/select UI patterns, reduces duplication, and improves maintainability by providing a single source of truth for Listbox styling and behavior. * refactor(ui): consolidate button, menu, popover, and range primitives for unified usage Remove legacy UI harness and dev/demo files. Replace scattered button, menu, popover, and range input utilities with shared, composable primitives: Button, ButtonLink, ButtonAnchor, MenuActionItem, MenuItemsSurface, PopoverSurface, PopoverTrigger, and RangeInput. Update all usages across app, admin, player, and document components to use these new primitives, eliminating duplicated class logic and improving consistency. Remove obsolete utility files and class exports. This change streamlines UI code, centralizes styling, and reduces maintenance overhead. * feat(ui): redesign range input with dynamic progress styling and improved accessibility Revamp the range input component to support dynamic progress indication using CSS custom properties and linear gradients. Add logic to compute and set the progress percentage based on current value, min, and max. Refine focus and disabled states for better accessibility and usability. Update styling for both WebKit and Mozilla engines to ensure consistent appearance. This change enhances visual feedback and modernizes the range slider UI. * style(ui): update range input to use secondary accent color for progress Switch range input progress styling from primary to secondary accent color for both WebKit and Mozilla engines. Remove drop shadow from slider thumb for a cleaner appearance. This change aligns the component with the updated design palette and simplifies visual effects. * refactor(app): remove unused Link imports from public pages and components Eliminate redundant imports of the Link component from Next.js in several public-facing pages and components. These imports were no longer in use after recent UI refactoring and consolidation of navigation elements. This cleanup reduces bundle size and improves code clarity. * chore(ui): remove unused export of segmented control classes from select component Eliminate unnecessary export statements for segmentedButtonClass and segmentedGroupClass in the select component to streamline the module's public API and reduce potential confusion. * test(accessibility): improve confirm dialog test coverage and refactor media state helper Expand accessibility tests for ConfirmDialog to assert dialog semantics, ARIA attributes, and visible destructive actions using test IDs. Refactor expectMediaState helper to check both UI control state and underlying media signals for more robust playback state detection. * refactor(ui): improve segmented control accessibility and update danger color tokens Update SegmentedControl to support full keyboard navigation and focus management, enhancing accessibility. Replace string indicator in Select with icon, and update button danger variant to use new --danger-strong variable for hover states. Add danger-strong token to Tailwind config and globals. Refine dropzone disabled behavior, adjust focus ring for better contrast, and apply minor UI consistency tweaks across components. * feat(ui): convert SidebarNavLink to forwardRef component Refactor SidebarNavLink to use React.forwardRef, enabling parent components to access the underlying anchor element's ref. Update prop typing and function signature accordingly for improved composability and integration with higher-order components.
This commit is contained in:
parent
3d0367bc5b
commit
f2c7760381
91 changed files with 3367 additions and 2582 deletions
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Link
|
||||
href="/app"
|
||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<p className="text-danger mb-4">{error}</p>
|
||||
<ButtonLink href="/app" variant="secondary" size="md" className="gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Documents
|
||||
</Link>
|
||||
</ButtonLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -188,16 +185,12 @@ export default function EPUBPage() {
|
|||
<>
|
||||
<Header
|
||||
left={
|
||||
<Link
|
||||
href="/app"
|
||||
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
aria-label="Back to documents"
|
||||
>
|
||||
<svg className="w-3 h-3 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<ButtonLink href="/app" variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
|
||||
<svg className="w-3 h-3" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Documents
|
||||
</Link>
|
||||
</ButtonLink>
|
||||
}
|
||||
title={isLoading ? 'Loading…' : (currDocName || '')}
|
||||
right={
|
||||
|
|
@ -242,7 +235,7 @@ export default function EPUBPage() {
|
|||
/>
|
||||
)}
|
||||
{isAtLimit ? (
|
||||
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
|
||||
<div className="sticky bottom-0 z-30 w-full border-t border-line-soft bg-surface" data-app-ttsbar>
|
||||
<div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10">
|
||||
<RateLimitPauseButton />
|
||||
<RateLimitBanner />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Link
|
||||
href="/app"
|
||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<p className="text-danger mb-4">{error}</p>
|
||||
<ButtonLink href="/app" variant="secondary" size="md" className="gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Documents
|
||||
</Link>
|
||||
</ButtonLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -175,16 +172,12 @@ export default function HTMLPage() {
|
|||
<>
|
||||
<Header
|
||||
left={
|
||||
<Link
|
||||
href="/app"
|
||||
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
aria-label="Back to documents"
|
||||
>
|
||||
<svg className="w-3 h-3 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<ButtonLink href="/app" variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
|
||||
<svg className="w-3 h-3" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Documents
|
||||
</Link>
|
||||
</ButtonLink>
|
||||
}
|
||||
title={isLoading ? 'Loading…' : (currDocName || '')}
|
||||
right={
|
||||
|
|
@ -233,7 +226,7 @@ export default function HTMLPage() {
|
|||
/>
|
||||
)}
|
||||
{isAtLimit ? (
|
||||
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
|
||||
<div className="sticky bottom-0 z-30 w-full border-t border-line-soft bg-surface" data-app-ttsbar>
|
||||
<div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10">
|
||||
<RateLimitPauseButton />
|
||||
<RateLimitBanner />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<div className="app-shell h-dvh flex flex-col bg-background overflow-hidden">
|
||||
<main className="flex-1 min-h-0 flex flex-col">{children}</main>
|
||||
</div>
|
||||
<AppShell>
|
||||
<AppMain>{children}</AppMain>
|
||||
</AppShell>
|
||||
<Toaster
|
||||
toastOptions={{
|
||||
style: {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { DocumentSettings } from '@/components/documents/DocumentSettings';
|
||||
|
|
@ -20,6 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
|||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||
import { LoadingSpinner } from '@/components/Spinner';
|
||||
import { Button, ButtonLink } from '@/components/ui';
|
||||
import {
|
||||
FORCE_REPARSE_CONFIRM_MESSAGE,
|
||||
FORCE_REPARSE_CONFIRM_TEXT,
|
||||
|
|
@ -251,17 +251,13 @@ export default function PDFViewerPage() {
|
|||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Link
|
||||
href="/app"
|
||||
onClick={handleBackToDocuments}
|
||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<p className="text-danger mb-4">{error}</p>
|
||||
<ButtonLink href="/app" onClick={handleBackToDocuments} variant="secondary" size="md" className="gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Documents
|
||||
</Link>
|
||||
</ButtonLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -308,67 +304,68 @@ export default function PDFViewerPage() {
|
|||
: (isMerging ? 'Stage: merge' : 'Stage: infer'));
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-base">
|
||||
<div className={`mx-auto flex h-full items-center px-4 py-6 transition-all duration-300 ease-out ${showDetailedParseLoader ? 'max-w-lg' : 'max-w-md'}`}>
|
||||
<div className="h-full w-full bg-surface">
|
||||
<div className={`mx-auto flex h-full items-center px-4 py-6 transition duration-slow ease-standard ${showDetailedParseLoader ? 'max-w-lg' : 'max-w-md'}`}>
|
||||
{showDetailedParseLoader ? (
|
||||
<div className="w-full rounded-xl border border-offbase bg-offbase/95 shadow-sm overflow-hidden">
|
||||
<div className="w-full rounded-lg border border-line bg-surface-sunken shadow-elev-1 overflow-hidden">
|
||||
<div className="h-1 bg-[linear-gradient(90deg,var(--accent),transparent_80%)]" />
|
||||
<div className="p-3.5 sm:p-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="inline-flex items-center gap-2 rounded-md border border-offbase bg-base/70 px-2.5 py-1">
|
||||
<div className="inline-flex items-center gap-2 rounded-md border border-line bg-surface-solid px-2.5 py-1">
|
||||
<LoadingSpinner className="h-3.5 w-3.5 text-accent" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted">PDF Layout Parse</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.08em] text-soft">PDF Layout Parse</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-foreground">{statusText}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-lg border border-offbase bg-base/75 p-2.5">
|
||||
<div className="mt-3 rounded-lg border border-line bg-surface-solid p-2.5">
|
||||
<div className="mb-1.5 flex items-end justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold text-foreground">
|
||||
{hasMeasuredProgress ? `Page ${pagesParsed} / ${totalPages}` : 'Awaiting first page'}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted">{stageLabel}</p>
|
||||
<p className="text-[10px] text-soft">{stageLabel}</p>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-offbase overflow-hidden">
|
||||
<div className="h-2 w-full rounded-full bg-surface-sunken overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent transition-all duration-300 ease-out"
|
||||
className="h-full bg-accent transition duration-slow ease-standard"
|
||||
style={{ width: `${hasMeasuredProgress ? progressPercent : 6}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10px] text-muted">
|
||||
<p className="mt-1.5 text-[10px] text-soft">
|
||||
{hasMeasuredProgress ? `${Math.round(progressPercent)}% complete` : statusSubText}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isLoading && parseUiState === 'failed' ? (
|
||||
<div className="mt-3 flex justify-start">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={requestForceReparse}
|
||||
className="inline-flex items-center rounded-md border border-offbase bg-base px-3 py-1.5 text-xs font-medium text-foreground hover:text-accent transition-colors"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Retry Parse
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full rounded-xl border border-offbase bg-offbase/95 p-4 shadow-sm transition-all duration-300 ease-out overflow-hidden">
|
||||
<div className="w-full rounded-lg border border-line bg-surface-sunken p-4 shadow-elev-1 transition duration-slow ease-standard overflow-hidden">
|
||||
<div className="h-0.5 -mx-4 -mt-4 mb-3 bg-[linear-gradient(90deg,var(--accent),transparent_75%)]" />
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">{compactLabel}</p>
|
||||
<p className="mt-1 text-xs text-muted">{compactSubLabel}</p>
|
||||
<p className="mt-1 text-xs text-soft">{compactSubLabel}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center justify-center rounded-md border border-offbase bg-base p-1.5">
|
||||
<span className="inline-flex items-center justify-center rounded-md border border-line bg-surface p-1.5">
|
||||
<LoadingSpinner className="h-3.5 w-3.5 text-accent" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-1.5">
|
||||
<span className="h-1.5 rounded-full bg-accent/30 animate-pulse" />
|
||||
<span className="h-1.5 rounded-full bg-accent/20 animate-pulse [animation-delay:120ms]" />
|
||||
<span className="h-1.5 rounded-full bg-accent/15 animate-pulse [animation-delay:220ms]" />
|
||||
<span className="h-1.5 rounded-full bg-accent-wash animate-pulse" />
|
||||
<span className="h-1.5 rounded-full bg-accent-wash animate-pulse [animation-delay:120ms]" />
|
||||
<span className="h-1.5 rounded-full bg-accent-wash animate-pulse [animation-delay:220ms]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -381,17 +378,12 @@ export default function PDFViewerPage() {
|
|||
<>
|
||||
<Header
|
||||
left={
|
||||
<Link
|
||||
href="/app"
|
||||
onClick={handleBackToDocuments}
|
||||
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
aria-label="Back to documents"
|
||||
>
|
||||
<svg className="w-3 h-3 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<ButtonLink href="/app" onClick={handleBackToDocuments} variant="secondary" size="sm" className="gap-2" aria-label="Back to documents">
|
||||
<svg className="w-3 h-3" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Documents
|
||||
</Link>
|
||||
</ButtonLink>
|
||||
}
|
||||
title={isLoading ? 'Loading…' : (currDocName || '')}
|
||||
right={
|
||||
|
|
@ -440,7 +432,7 @@ export default function PDFViewerPage() {
|
|||
/>
|
||||
)}
|
||||
{isAtLimit ? (
|
||||
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
|
||||
<div className="sticky bottom-0 z-30 w-full border-t border-line-soft bg-surface" data-app-ttsbar>
|
||||
<div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10">
|
||||
<RateLimitPauseButton />
|
||||
<RateLimitBanner />
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<SessionExpiredLoader setSessionExpired={setSessionExpired} />
|
||||
</Suspense>
|
||||
|
||||
<div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6">
|
||||
<Surface elevation="3" className="w-full max-w-md p-6">
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
{sessionExpired ? 'Session Expired' : 'Connect Account'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
<p className="text-sm text-soft mt-1">
|
||||
{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 && (
|
||||
<div className="mt-4 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400">
|
||||
<div className="mt-4 p-3 bg-accent-wash border border-accent-line rounded-lg">
|
||||
<p className="text-sm text-accent ">
|
||||
Your session has expired. Please sign in again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
<div className="mt-4 p-3 bg-danger-wash border border-danger rounded-lg">
|
||||
<p className="text-sm text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
|
||||
<Field label="Email">
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
|
||||
<Field label="Password">
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Remember Me */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
|
|
@ -178,7 +173,7 @@ function SignInContent() {
|
|||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="rounded border-muted text-accent focus:ring-accent"
|
||||
className="rounded border-muted text-accent focus:ring-accent-line"
|
||||
/>
|
||||
<span className="text-sm text-foreground">Remember me</span>
|
||||
</label>
|
||||
|
|
@ -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 ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Connect'}
|
||||
</Button>
|
||||
|
|
@ -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 ? (
|
||||
<LoadingSpinner className="w-4 h-4" />
|
||||
|
|
@ -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 ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Continue anonymously'}
|
||||
</Button>
|
||||
|
|
@ -230,16 +227,16 @@ function SignInContent() {
|
|||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-4 border-t border-offbase text-center space-y-2">
|
||||
<div className="mt-6 pt-4 border-t border-line-soft text-center space-y-2">
|
||||
{enableUserSignups && (
|
||||
<p className="text-xs text-muted">
|
||||
<p className="text-xs text-soft">
|
||||
Don't have an account?{' '}
|
||||
<Link href="/signup" className="underline hover:text-foreground">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted">
|
||||
<p className="text-xs text-soft">
|
||||
By signing in, you agree to our{' '}
|
||||
<button
|
||||
onClick={() => showPrivacyModal()}
|
||||
|
|
@ -249,7 +246,7 @@ function SignInContent() {
|
|||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||
<div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6">
|
||||
<Surface elevation="3" className="w-full max-w-md p-6">
|
||||
<h1 className="text-xl font-semibold text-foreground">Sign-ups unavailable</h1>
|
||||
<p className="text-sm text-muted mt-1">
|
||||
<p className="text-sm text-soft mt-1">
|
||||
New account sign-ups are currently disabled by the site administrator.
|
||||
</p>
|
||||
<div className="mt-6 pt-4 border-t border-offbase text-center">
|
||||
<p className="text-xs text-muted">
|
||||
<div className="mt-6 pt-4 border-t border-line-soft text-center">
|
||||
<p className="text-xs text-soft">
|
||||
Already have an account?{' '}
|
||||
<Link href="/signin" className="underline hover:text-foreground">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||
<div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6">
|
||||
<Surface elevation="3" className="w-full max-w-md p-6">
|
||||
<h1 className="text-xl font-semibold text-foreground">Sign Up</h1>
|
||||
<p className="text-sm text-muted mt-1">Create your account to get started</p>
|
||||
<p className="text-sm text-soft mt-1">Create your account to get started</p>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
<div className="mt-4 p-3 bg-danger-wash border border-danger rounded-lg">
|
||||
<p className="text-sm text-danger">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
|
||||
<Field label="Email">
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
|
||||
<Field label="Password">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={buttonClass({
|
||||
variant: 'ghost',
|
||||
size: 'icon',
|
||||
className: 'absolute right-2 top-1/2 -translate-y-1/2 h-7 w-7 text-muted',
|
||||
})}
|
||||
className="absolute right-2 top-1/2 h-7 w-7 -translate-y-1/2"
|
||||
>
|
||||
{showPassword ? '👁️' : '👁️🗨️'}
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{/* Password Strength */}
|
||||
|
|
@ -185,17 +176,17 @@ export default function SignUpPage() {
|
|||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1 flex-1 rounded ${i < strength ? strengthColors[strength - 1] : 'bg-offbase'
|
||||
className={`h-1 flex-1 rounded ${i < strength ? strengthColors[strength - 1] : 'bg-surface-sunken'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className={`text-xs ${strength >= 3 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<p className={`text-xs ${strength >= 3 ? 'text-accent' : 'text-danger'}`}>
|
||||
{strengthLabels[strength - 1] || 'Very Weak'}
|
||||
</p>
|
||||
<div className="text-xs space-y-0.5 text-muted">
|
||||
<div className="text-xs space-y-0.5 text-soft">
|
||||
{Object.entries(checks).map(([key, passed]) => (
|
||||
<div key={key} className={`flex items-center gap-1 ${passed ? 'text-green-600' : ''}`}>
|
||||
<div key={key} className={`flex items-center gap-1 ${passed ? 'text-accent' : ''}`}>
|
||||
<span>{passed ? '✓' : '○'}</span>
|
||||
<span>
|
||||
{key === 'length' && 'At least 8 characters'}
|
||||
|
|
@ -209,46 +200,46 @@ export default function SignUpPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Confirm Password</label>
|
||||
<Field label="Confirm Password">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={passwordConfirmation}
|
||||
onChange={(e) => 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 && (
|
||||
<p className={`text-xs mt-1 ${password === passwordConfirmation ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<p className={`text-xs mt-1 ${password === passwordConfirmation ? 'text-accent' : 'text-danger'}`}>
|
||||
{password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Sign Up Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
onClick={handleSignUp}
|
||||
className={buttonClass({ variant: 'primary', size: 'md', className: 'w-full hover:scale-[1.02]' })}
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="w-full"
|
||||
>
|
||||
{loading ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Create Account'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-4 border-t border-offbase text-center space-y-2">
|
||||
<p className="text-xs text-muted">
|
||||
<div className="mt-6 pt-4 border-t border-line-soft text-center space-y-2">
|
||||
<p className="text-xs text-soft">
|
||||
Already have an account?{' '}
|
||||
<Link href="/signin" className="underline hover:text-foreground">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-xs text-muted">
|
||||
<p className="text-xs text-soft">
|
||||
By creating an account, you agree to our{' '}
|
||||
<button
|
||||
onClick={() => showPrivacyModal()}
|
||||
|
|
@ -258,7 +249,7 @@ export default function SignUpPage() {
|
|||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</a>
|
||||
<span className="public-nav-divider" aria-hidden="true" />
|
||||
<Link href="/signin" className={buttonClass({ variant: 'ghost', size: 'sm' })}>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'sm' })}>
|
||||
Open app
|
||||
</Link>
|
||||
<ButtonLink href="/signin" variant="ghost" size="sm">Sign in</ButtonLink>
|
||||
<ButtonLink href="/app" variant="primary" size="sm">Open app</ButtonLink>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
|
|
@ -70,18 +66,11 @@ export default async function PublicLayout({ children }: { children: ReactNode }
|
|||
</p>
|
||||
<div className="public-footer-cta">
|
||||
{enableUserSignups ? (
|
||||
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'sm' })}>
|
||||
Create account
|
||||
</Link>
|
||||
<ButtonLink href="/signup" variant="outline" size="sm">Create account</ButtonLink>
|
||||
) : null}
|
||||
<a
|
||||
href="https://github.com/richardr1126/openreader"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonClass({ variant: 'ghost', size: 'sm' })}
|
||||
>
|
||||
<ButtonAnchor href="https://github.com/richardr1126/openreader" target="_blank" rel="noopener noreferrer" variant="ghost" size="sm">
|
||||
Star on GitHub
|
||||
</a>
|
||||
</ButtonAnchor>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</p>
|
||||
|
||||
<div className="public-actions">
|
||||
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'lg' })}>
|
||||
Open the reader
|
||||
</Link>
|
||||
<ButtonLink href="/app" variant="primary" size="lg">Open the reader</ButtonLink>
|
||||
{enableUserSignups ? (
|
||||
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'lg' })}>
|
||||
Create account
|
||||
</Link>
|
||||
<ButtonLink href="/signup" variant="outline" size="lg">Create account</ButtonLink>
|
||||
) : (
|
||||
<Link href="/signin" className={buttonClass({ variant: 'outline', size: 'lg' })}>
|
||||
Sign in
|
||||
</Link>
|
||||
<ButtonLink href="/signin" variant="outline" size="lg">Sign in</ButtonLink>
|
||||
)}
|
||||
<Link
|
||||
href="https://docs.openreader.richardr.dev/"
|
||||
className={buttonClass({ variant: 'ghost', size: 'lg' })}
|
||||
>
|
||||
Read the docs →
|
||||
</Link>
|
||||
<ButtonAnchor href="https://docs.openreader.richardr.dev/" target="_blank" rel="noopener noreferrer" variant="ghost" size="lg">Read the docs →</ButtonAnchor>
|
||||
</div>
|
||||
|
||||
<div className="public-formats" aria-label="Supported formats">
|
||||
|
|
@ -316,22 +304,12 @@ export default async function LandingPage() {
|
|||
external compute worker. Every piece is yours to host.
|
||||
</p>
|
||||
<div className="public-actions">
|
||||
<a
|
||||
href="https://github.com/richardr1126/openreader#readme"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonClass({ variant: 'primary', size: 'lg' })}
|
||||
>
|
||||
<ButtonAnchor href="https://github.com/richardr1126/openreader#readme" target="_blank" rel="noopener noreferrer" variant="primary" size="lg">
|
||||
View the repository
|
||||
</a>
|
||||
<a
|
||||
href="https://docs.openreader.richardr.dev/docker-quick-start"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonClass({ variant: 'outline', size: 'lg' })}
|
||||
>
|
||||
</ButtonAnchor>
|
||||
<ButtonAnchor href="https://docs.openreader.richardr.dev/docker-quick-start" target="_blank" rel="noopener noreferrer" variant="outline" size="lg">
|
||||
Deployment guides
|
||||
</a>
|
||||
</ButtonAnchor>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,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.
|
||||
</p>
|
||||
<div className="policy-actions">
|
||||
<a
|
||||
href="https://github.com/richardr1126/openreader/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonClass({ variant: 'primary', size: 'md' })}
|
||||
>
|
||||
<ButtonAnchor href="https://github.com/richardr1126/openreader/issues" target="_blank" rel="noopener noreferrer" variant="primary" size="md">
|
||||
Open an Issue
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/richardr1126/openreader#readme"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonClass({ variant: 'outline', size: 'md' })}
|
||||
>
|
||||
</ButtonAnchor>
|
||||
<ButtonAnchor href="https://github.com/richardr1126/openreader#readme" target="_blank" rel="noopener noreferrer" variant="outline" size="md">
|
||||
Self-Hosting Guide
|
||||
</a>
|
||||
<Link href="/?redirect=false" className={buttonClass({ variant: 'ghost', size: 'md' })}>
|
||||
Back to landing
|
||||
</Link>
|
||||
</ButtonAnchor>
|
||||
<ButtonLink href="/?redirect=false" variant="ghost" size="md">Back to landing</ButtonLink>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<>
|
||||
<div className="space-y-4">
|
||||
{!isGenerating && (
|
||||
<div className="w-full rounded-xl border border-offbase bg-background">
|
||||
<div className="w-full rounded-lg border border-line bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-offbase bg-base rounded-t-xl">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-line-soft bg-surface rounded-t-xl">
|
||||
<h4 className="text-sm font-medium text-foreground tracking-tight">Generation settings</h4>
|
||||
{settingsLocked && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-offbase px-2 py-0.5 text-[11px] font-medium text-muted uppercase tracking-wider">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-surface-sunken px-2 py-0.5 text-[11px] font-medium text-soft uppercase tracking-wider">
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor"><path fillRule="evenodd" d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7A1.5 1.5 0 0 0 3 8.5v5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 11.5 7V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z" clipRule="evenodd" /></svg>
|
||||
Locked
|
||||
</span>
|
||||
|
|
@ -490,9 +491,9 @@ export function AudiobookExportModal({
|
|||
|
||||
<div className="p-4">
|
||||
{isLegacyAudiobookMissingSettings && (
|
||||
<div className="mb-4 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-xs text-foreground">
|
||||
<div className="mb-4 rounded-lg border border-accent-line bg-accent-wash p-3 text-xs text-foreground">
|
||||
<div className="font-medium">Saved generation settings not found</div>
|
||||
<div className="mt-1 text-muted">
|
||||
<div className="mt-1 text-soft">
|
||||
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 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg bg-base p-3">
|
||||
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Voice</div>
|
||||
<Card className="p-3">
|
||||
<div className="text-[11px] uppercase tracking-wider text-soft mb-1">Voice</div>
|
||||
<div className="text-sm font-medium text-foreground truncate">{savedSettings.voice}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-base p-3">
|
||||
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Format</div>
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<div className="text-[11px] uppercase tracking-wider text-soft mb-1">Format</div>
|
||||
<div className="text-sm font-medium text-foreground">{savedSettings.format.toUpperCase()}</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg bg-base p-3">
|
||||
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Native speed</div>
|
||||
<Card className="p-3">
|
||||
<div className="text-[11px] uppercase tracking-wider text-soft mb-1">Native speed</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{resolveTtsProviderModelPolicy({
|
||||
providerRef: savedSettings.providerRef,
|
||||
|
|
@ -524,13 +525,13 @@ export function AudiobookExportModal({
|
|||
? `${formatSpeed(savedSettings.nativeSpeed)}x`
|
||||
: 'Not supported'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-base p-3">
|
||||
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Post speed</div>
|
||||
</Card>
|
||||
<Card className="p-3">
|
||||
<div className="text-[11px] uppercase tracking-wider text-soft mb-1">Post speed</div>
|
||||
<div className="text-sm font-medium text-foreground">{formatSpeed(savedSettings.postSpeed)}x</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<p className="text-xs text-muted">
|
||||
<p className="text-xs text-soft">
|
||||
Reset the audiobook to change generation settings.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -539,7 +540,7 @@ export function AudiobookExportModal({
|
|||
{/* Voice & Format row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Voice</label>
|
||||
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Voice</label>
|
||||
<VoicesControlBase
|
||||
availableVoices={availableVoices}
|
||||
voice={audiobookVoice}
|
||||
|
|
@ -552,7 +553,7 @@ export function AudiobookExportModal({
|
|||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Format</label>
|
||||
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Format</label>
|
||||
{chapters.length === 0 ? (
|
||||
<Listbox
|
||||
value={format}
|
||||
|
|
@ -560,44 +561,42 @@ export function AudiobookExportModal({
|
|||
disabled={chapters.length > 0 || settingsLocked}
|
||||
>
|
||||
<div className="relative">
|
||||
<ListboxButton className="relative cursor-pointer rounded-lg bg-base py-1.5 pl-3 pr-10 text-left text-foreground focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent w-full">
|
||||
<SharedListboxButton className="bg-surface">
|
||||
<span className="block truncate text-sm font-medium">{format.toUpperCase()}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
|
||||
<ChevronUpDownIcon className="h-4 w-4 text-soft" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions className="absolute left-0 mt-1 max-h-60 w-full overflow-auto rounded-lg bg-base py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10">
|
||||
<ListboxOption
|
||||
<SharedListboxOptions className="absolute left-0 mt-1 w-full">
|
||||
<SharedListboxOption
|
||||
value="m4b"
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
|
||||
}
|
||||
inset="none"
|
||||
itemClassName="py-2"
|
||||
>
|
||||
{({ selected }) => (
|
||||
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
M4B
|
||||
</span>
|
||||
)}
|
||||
</ListboxOption>
|
||||
<ListboxOption
|
||||
</SharedListboxOption>
|
||||
<SharedListboxOption
|
||||
value="mp3"
|
||||
className={({ active }) =>
|
||||
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
|
||||
}
|
||||
inset="none"
|
||||
itemClassName="py-2"
|
||||
>
|
||||
{({ selected }) => (
|
||||
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
MP3
|
||||
</span>
|
||||
)}
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</SharedListboxOption>
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
|
|
@ -608,9 +607,9 @@ export function AudiobookExportModal({
|
|||
</div>
|
||||
|
||||
{/* Speed controls */}
|
||||
<div className="rounded-lg bg-base p-3 space-y-3">
|
||||
<Card className="p-3 space-y-3">
|
||||
{!nativeSpeedSupported && (
|
||||
<div className="rounded-md border border-offbase bg-background px-2 py-1.5 text-[11px] text-muted">
|
||||
<div className="rounded-md border border-line bg-background px-2 py-1.5 text-[11px] text-soft">
|
||||
Native model speed is not available for this model.
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -619,48 +618,44 @@ export function AudiobookExportModal({
|
|||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Native model speed</label>
|
||||
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Native model speed</label>
|
||||
<span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(nativeSpeed)}x</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
<RangeInput
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={nativeSpeed}
|
||||
onChange={(e) => setNativeSpeed(parseFloat(e.target.value))}
|
||||
className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent [&::-moz-range-thumb]:border-0"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted">
|
||||
<div className="flex justify-between text-[10px] text-soft">
|
||||
<span>0.5x</span>
|
||||
<span>3x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-offbase" />
|
||||
<div className="border-t border-line-soft" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Post-generation speed</label>
|
||||
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Post-generation speed</label>
|
||||
<span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(postSpeed)}x</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
<RangeInput
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={postSpeed}
|
||||
onChange={(e) => setPostSpeed(parseFloat(e.target.value))}
|
||||
className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent [&::-moz-range-thumb]:border-0"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted">
|
||||
<div className="flex justify-between text-[10px] text-soft">
|
||||
<span>0.5x</span>
|
||||
<span>3x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -670,10 +665,9 @@ export function AudiobookExportModal({
|
|||
<Button
|
||||
onClick={handleStartGeneration}
|
||||
disabled={!canGenerate}
|
||||
className="flex-1 inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm
|
||||
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex-1"
|
||||
>
|
||||
Start Generation
|
||||
</Button>
|
||||
|
|
@ -682,10 +676,9 @@ export function AudiobookExportModal({
|
|||
<Button
|
||||
onClick={handleStartGeneration}
|
||||
disabled={!canGenerate}
|
||||
className="flex-1 inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm
|
||||
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="flex-1"
|
||||
>
|
||||
Resume
|
||||
</Button>
|
||||
|
|
@ -694,10 +687,8 @@ export function AudiobookExportModal({
|
|||
<Button
|
||||
onClick={() => setShowResetConfirm(true)}
|
||||
disabled={isGenerating}
|
||||
className="inline-flex justify-center rounded-lg border border-red-500 bg-transparent px-3 py-2 text-sm
|
||||
font-medium text-red-500 hover:bg-red-500 hover:text-background focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.01]"
|
||||
variant="danger"
|
||||
size="md"
|
||||
title="Delete all generated chapters/pages for this document"
|
||||
>
|
||||
Reset
|
||||
|
|
@ -708,21 +699,21 @@ export function AudiobookExportModal({
|
|||
</div>
|
||||
)}
|
||||
{showRegenerateHint && (
|
||||
<div className="flex items-start justify-between bg-offbase border border-offbase rounded-md px-3 py-2 text-xs sm:text-sm">
|
||||
<div className="flex items-start justify-between bg-surface-sunken border border-line rounded-md px-3 py-2 text-xs sm:text-sm">
|
||||
<p className="text-xs sm:text-sm text-foreground">
|
||||
TTS audio for this chapter may be cached
|
||||
<br />
|
||||
Change the TTS playback options or restart the server to force uncached regeneration
|
||||
</p>
|
||||
<Button
|
||||
<IconButton
|
||||
onClick={() => setShowRegenerateHint(false)}
|
||||
className="ml-3 p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
|
||||
className="ml-3"
|
||||
aria-label="Dismiss regenerate hint"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
{/* Progress Info */}
|
||||
|
|
@ -746,34 +737,36 @@ export function AudiobookExportModal({
|
|||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-foreground">Chapters</h4>
|
||||
{isRefreshingChapters && <ClockIcon className="h-4 w-4 text-muted animate-spin" />}
|
||||
{isRefreshingChapters && <ClockIcon className="h-4 w-4 text-soft animate-spin" />}
|
||||
</div>
|
||||
{displayChapters.map((chapter) => (
|
||||
<div
|
||||
key={chapter.index}
|
||||
className={`flex items-center justify-between px-2 sm:px-3 py-1 sm:py-1.5 rounded-lg bg-offbase ${(regeneratingChapter === chapter.index || chapter.status === 'generating') ? 'prism-outline' : ''}`}
|
||||
className={`flex items-center justify-between px-2 sm:px-3 py-1 sm:py-1.5 rounded-lg bg-surface-sunken ${(regeneratingChapter === chapter.index || chapter.status === 'generating') ? 'prism-outline' : ''}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
{chapter.status === 'completed' ? (
|
||||
<CheckCircleIcon className="h-5 w-5 text-accent" />
|
||||
) : onRegenerateChapter ? (
|
||||
<Button
|
||||
<IconButton
|
||||
onClick={() => handleRegenerateChapter(chapter)}
|
||||
disabled={regeneratingChapter !== null || chapter.status === 'generating' || isGenerating}
|
||||
className="inline-flex items-center justify-center rounded-full bg-offbase text-accent hover:bg-accent/20 p-1.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
tone="ghost"
|
||||
size="sm"
|
||||
className="rounded-full bg-surface-sunken text-accent"
|
||||
title={chapter.status === 'generating' ? 'Generating...' : 'Regenerate this chapter'}
|
||||
>
|
||||
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index || chapter.status === 'generating' ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</IconButton>
|
||||
) : (
|
||||
<ClockIcon className="h-5 w-5 text-muted" />
|
||||
<ClockIcon className="h-5 w-5 text-soft" />
|
||||
)}
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{chapter.title}
|
||||
</p>
|
||||
<p>•</p>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
<p className="text-xs text-soft mt-0.5">
|
||||
{chapter.status !== 'completed' && <span className="text-warning">Missing • </span>}{formatDuration(chapter.duration)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -782,82 +775,63 @@ export function AudiobookExportModal({
|
|||
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<MenuButton
|
||||
className="inline-flex items-center justify-center rounded-md p-1.5 hover:bg-background focus:outline-none focus-visible:ring-2 focus-visible:ring-accent text-muted hover:text-foreground transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||
as={IconButton}
|
||||
size="sm"
|
||||
title="Chapter actions"
|
||||
>
|
||||
<DotsVerticalIcon className="h-5 w-5" />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enter="transition ease-standard duration-fast"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
<MenuItemsSurface
|
||||
anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }}
|
||||
portal
|
||||
className="w-44 rounded-md bg-background shadow-lg ring-1 ring-black/5 focus:outline-none z-[70] p-1 origin-top-right"
|
||||
className="z-[70] w-44 origin-top-right bg-background focus:outline-none"
|
||||
>
|
||||
{chapter.status === 'completed' && (
|
||||
<>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => setPendingDeleteChapter(chapter)}
|
||||
className={`${active ? 'bg-offbase' : ''} text-red-500 group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
|
||||
title="Delete this chapter"
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => handleDownloadChapter(chapter)}
|
||||
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
|
||||
>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
<span>Download</span>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuActionItem
|
||||
tone="danger"
|
||||
onClick={() => setPendingDeleteChapter(chapter)}
|
||||
title="Delete this chapter"
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
<span>Delete</span>
|
||||
</MenuActionItem>
|
||||
<MenuActionItem onClick={() => handleDownloadChapter(chapter)}>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
<span>Download</span>
|
||||
</MenuActionItem>
|
||||
</>
|
||||
)}
|
||||
{regeneratingChapter === chapter.index && (
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className={`${active ? 'bg-offbase text-red-500' : 'text-red-500'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
|
||||
title="Cancel this chapter regeneration"
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuActionItem
|
||||
tone="danger"
|
||||
onClick={handleCancel}
|
||||
title="Cancel this chapter regeneration"
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
<span>Cancel</span>
|
||||
</MenuActionItem>
|
||||
)}
|
||||
{onRegenerateChapter && !isGenerating && (
|
||||
<MenuItem disabled={regeneratingChapter !== null}>
|
||||
{({ active, disabled }) => (
|
||||
<button
|
||||
onClick={() => handleRegenerateChapter(chapter)}
|
||||
disabled={disabled}
|
||||
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
title="Regenerate this chapter"
|
||||
>
|
||||
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
|
||||
<span>{regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'}</span>
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuActionItem
|
||||
disabled={regeneratingChapter !== null}
|
||||
onClick={() => handleRegenerateChapter(chapter)}
|
||||
title="Regenerate this chapter"
|
||||
>
|
||||
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
|
||||
<span>{regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'}</span>
|
||||
</MenuActionItem>
|
||||
)}
|
||||
</MenuItems>
|
||||
</MenuItemsSurface>
|
||||
{/* end of menu items */}
|
||||
</Transition>
|
||||
</Menu>
|
||||
|
|
@ -868,15 +842,13 @@ export function AudiobookExportModal({
|
|||
</div>
|
||||
|
||||
{bookId && !isGenerating && (
|
||||
<div className="pt-4 border-t border-offbase">
|
||||
<div className="pt-4 border-t border-line-soft">
|
||||
<Button
|
||||
onClick={handleDownloadComplete}
|
||||
disabled={isCombining}
|
||||
className="w-full inline-flex justify-center items-center space-x-2 rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
|
||||
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="w-full space-x-2"
|
||||
>
|
||||
<DownloadIcon className="h-5 w-5" />
|
||||
<span>{isCombining ? 'Combining chapters...' : `Full Download (${format.toUpperCase()})`}</span>
|
||||
|
|
@ -888,7 +860,7 @@ export function AudiobookExportModal({
|
|||
|
||||
{chapters.length === 0 && !isGenerating && !isLoadingExisting && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted">
|
||||
<p className="text-sm text-soft">
|
||||
Audiobook settings are fixed after generation. Chapters will appear here as they are ready.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -938,43 +910,43 @@ export function AudiobookExportModal({
|
|||
function AudiobookSettingsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse" aria-label="Loading audiobook settings" aria-busy="true">
|
||||
<div className="w-full rounded-xl border border-offbase bg-background overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-offbase bg-base">
|
||||
<div className="h-4 w-40 rounded bg-offbase" />
|
||||
<div className="h-5 w-14 rounded bg-offbase" />
|
||||
<div className="w-full rounded-lg border border-line bg-background overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-line-soft bg-surface">
|
||||
<div className="h-4 w-40 rounded bg-surface-sunken" />
|
||||
<div className="h-5 w-14 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-16 rounded bg-offbase" />
|
||||
<div className="h-9 w-full rounded-md bg-offbase" />
|
||||
<div className="h-3 w-16 rounded bg-surface-sunken" />
|
||||
<div className="h-9 w-full rounded-md bg-surface-sunken" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-16 rounded bg-offbase" />
|
||||
<div className="h-9 w-full rounded-md bg-offbase" />
|
||||
<div className="h-3 w-16 rounded bg-surface-sunken" />
|
||||
<div className="h-9 w-full rounded-md bg-surface-sunken" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-24 rounded bg-offbase" />
|
||||
<div className="h-2 w-full rounded bg-offbase" />
|
||||
<div className="h-3 w-24 rounded bg-surface-sunken" />
|
||||
<div className="h-2 w-full rounded bg-surface-sunken" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-20 rounded bg-offbase" />
|
||||
<div className="h-2 w-full rounded bg-offbase" />
|
||||
<div className="h-3 w-20 rounded bg-surface-sunken" />
|
||||
<div className="h-2 w-full rounded bg-surface-sunken" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-9 w-full rounded-md bg-offbase" />
|
||||
<div className="h-9 w-full rounded-md bg-surface-sunken" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full rounded-xl border border-offbase bg-background overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-offbase bg-base">
|
||||
<div className="h-4 w-28 rounded bg-offbase" />
|
||||
<div className="w-full rounded-lg border border-line bg-background overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-line-soft bg-surface">
|
||||
<div className="h-4 w-28 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
<div className="p-4 space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="h-16 rounded-lg border border-offbase bg-base" />
|
||||
<div key={index} className="h-16 rounded-lg border border-line bg-surface" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState } from 'react';
|
||||
import { CopyIcon, CheckIcon } from '@/components/icons/Icons';
|
||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
||||
import { Button } from '@/components/ui';
|
||||
|
||||
export function CodeBlock({ children }: { children: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
|
@ -16,20 +16,18 @@ export function CodeBlock({ children }: { children: string }) {
|
|||
return (
|
||||
<div className="relative group my-2">
|
||||
<pre className="bg-background p-3 rounded-md overflow-x-auto text-xs text-left
|
||||
font-mono text-foreground border border-offbase">
|
||||
font-mono text-foreground border border-line">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
<button
|
||||
<Button
|
||||
onClick={handleCopy}
|
||||
className={buttonClass({
|
||||
variant: 'secondary',
|
||||
size: 'icon',
|
||||
className: 'absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100 hover:scale-[1.05]',
|
||||
})}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{copied ? <CheckIcon className="w-4 h-4" /> : <CopyIcon className="w-4 h-4" />}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,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 (
|
||||
<Popover className="relative flex items-center">
|
||||
<PopoverButton className="cursor-pointer group focus:outline-none">
|
||||
<PopoverButton as={IconButton} size="sm" className="group rounded-full p-0" aria-label={`Pick ${label} color`}>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border-2 transition-all duration-150 group-hover:scale-110 group-focus-visible:ring-2 group-focus-visible:ring-offset-1"
|
||||
className="w-6 h-6 rounded-full border-2 transition duration-fast group-focus-visible:ring-2 group-focus-visible:ring-offset-1"
|
||||
style={{
|
||||
backgroundColor: value,
|
||||
borderColor: isLightColor(value) ? '#00000022' : '#ffffff22',
|
||||
|
|
@ -88,11 +89,10 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
|
|||
/>
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel
|
||||
<PopoverSurface
|
||||
anchor="bottom start"
|
||||
transition
|
||||
className="z-[60] mt-2 w-56 rounded-xl shadow-xl border border-offbase bg-background p-3 space-y-3
|
||||
transition duration-150 ease-out data-[closed]:opacity-0 data-[closed]:scale-95"
|
||||
className="z-[60] mt-2 w-56 bg-background space-y-3 transition duration-fast ease-standard data-[closed]:opacity-0 data-[closed]:scale-95"
|
||||
>
|
||||
{/* Label */}
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -101,14 +101,14 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
|
|||
</span>
|
||||
{/* Eyedropper / native picker */}
|
||||
<div className="relative">
|
||||
<button
|
||||
<IconButton
|
||||
type="button"
|
||||
onClick={() => nativeRef.current?.click()}
|
||||
className="p-1"
|
||||
size="xs"
|
||||
aria-label="Open system color picker"
|
||||
>
|
||||
<PaletteIcon className="w-4 h-4 text-muted transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
|
||||
</button>
|
||||
<PaletteIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
|
||||
</IconButton>
|
||||
<input
|
||||
ref={nativeRef}
|
||||
type="color"
|
||||
|
|
@ -130,7 +130,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
|
|||
key={color}
|
||||
type="button"
|
||||
onClick={() => onChange(color)}
|
||||
className="group/swatch relative w-full aspect-square rounded-full transition-transform duration-100 hover:scale-125 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1"
|
||||
className="group/swatch relative w-full aspect-square rounded-full transition-transform duration-fast focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-1"
|
||||
style={{
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={hexInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</PopoverSurface>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
role={undefined}
|
||||
className="relative z-50"
|
||||
onClose={onClose}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
<ModalFrame open={isOpen} onClose={onClose} onKeyDown={handleKeyDown} panelTestId="confirm-dialog-panel">
|
||||
<ModalTitle>{title}</ModalTitle>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-soft break-words">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end space-x-3">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isDangerous ? 'danger' : 'primary'}
|
||||
size="sm"
|
||||
className="text-wrap"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel role='dialog' className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
className="text-lg font-semibold leading-6 text-foreground"
|
||||
>
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-foreground/90 break-words">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass({
|
||||
variant: 'outline',
|
||||
size: 'sm',
|
||||
className: 'hover:scale-[1.04]',
|
||||
})}
|
||||
onClick={onClose}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass({
|
||||
variant: isDangerous ? 'danger' : 'primary',
|
||||
size: 'sm',
|
||||
className: 'text-wrap hover:scale-[1.04]',
|
||||
})}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFrame>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Transition
|
||||
as="div"
|
||||
show={show}
|
||||
enter="transition ease-out duration-300 transform"
|
||||
enter="transition ease-standard duration-slow transform"
|
||||
enterFrom="translate-y-full opacity-0"
|
||||
enterTo="translate-y-0 opacity-100"
|
||||
leave="transition ease-in duration-200 transform"
|
||||
leave="transition ease-standard duration-base transform"
|
||||
leaveFrom="translate-y-0 opacity-100"
|
||||
leaveTo="translate-y-full opacity-0"
|
||||
className="fixed bottom-0 left-0 right-0 z-[60] px-4 pt-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6 md:pt-6 md:pb-[max(1.5rem,env(safe-area-inset-bottom))]"
|
||||
>
|
||||
<div className="mx-auto max-w-5xl rounded-xl border border-offbase bg-base p-5 shadow-2xl md:flex md:items-center md:justify-between md:gap-8">
|
||||
<div className="mx-auto max-w-5xl rounded-lg border border-line bg-surface p-5 shadow-elev-3 md:flex md:items-center md:justify-between md:gap-8">
|
||||
<div className="mb-4 md:mb-0">
|
||||
<h3 className="mb-2 text-lg font-bold">
|
||||
🍪 We use cookies
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-foreground/90">
|
||||
<p className="text-sm leading-relaxed text-soft">
|
||||
We use strictly necessary cookies for authentication. Optional analytics is enabled only when you consent.
|
||||
See our <Link href="/privacy" className="font-medium text-accent hover:underline">Privacy Policy</Link> for details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 min-w-fit sm:flex-row">
|
||||
<button
|
||||
<Button
|
||||
onClick={handleDecline}
|
||||
className={buttonClass({
|
||||
variant: 'ghost',
|
||||
size: 'md',
|
||||
className: 'whitespace-nowrap',
|
||||
})}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
Decline Non-Essential
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAccept}
|
||||
className={buttonClass({
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
className: 'whitespace-nowrap font-bold shadow-sm',
|
||||
})}
|
||||
variant="primary"
|
||||
size="md"
|
||||
className="whitespace-nowrap font-bold shadow-elev-1"
|
||||
>
|
||||
Accept All
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="sticky top-0 z-40 w-full border-b border-offbase bg-base" data-app-header>
|
||||
<div className="px-2 sm:px-3 py-1 flex items-center justify-between gap-2 min-h-10">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{left}
|
||||
{typeof title === 'string' ? (
|
||||
<h1 className="text-xs md:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0 justify-end">{right}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <AppHeader left={left} title={title} right={right} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ export function HomeContent() {
|
|||
const appActions = (
|
||||
<div className="flex flex-col gap-0.5 w-full">
|
||||
<SettingsTrigger
|
||||
variant="sidebar"
|
||||
triggerLabel="Settings"
|
||||
className="w-full justify-start gap-2 px-2 py-1 text-[12px] border-transparent hover:border-accent"
|
||||
onOpen={() => setSettingsOpen(true)}
|
||||
/>
|
||||
<UserMenu variant="sidebar" />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="mt-4 space-y-4 text-sm text-foreground/90">
|
||||
<div className="rounded-lg border border-offbase bg-offbase/40 p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Service Operator</div>
|
||||
<div className="mt-4 space-y-4 text-sm text-soft">
|
||||
<div className="rounded-lg border border-line bg-surface-sunken p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-soft">Service Operator</div>
|
||||
<div className="mt-1">
|
||||
This instance is hosted at <span className="font-bold">{origin || 'this server'}</span>.
|
||||
</div>
|
||||
|
|
@ -77,85 +70,46 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
|
|||
};
|
||||
|
||||
return (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-[80]" onClose={onDismiss ?? (() => { })}>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
<ModalFrame open={isOpen} onClose={onDismiss ?? (() => {})} panelTestId="privacy-modal" className="z-[80]">
|
||||
<ModalTitle>Privacy & Data Usage</ModalTitle>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel data-testid="privacy-modal" className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
className="text-lg font-semibold leading-6 text-foreground"
|
||||
>
|
||||
Privacy & Data Usage
|
||||
</DialogTitle>
|
||||
<PrivacyModalBody origin={origin} />
|
||||
|
||||
<PrivacyModalBody origin={origin} />
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-offbase p-3 bg-offbase/20">
|
||||
<div className="flex h-6 items-center">
|
||||
<input
|
||||
data-testid="privacy-agree-checkbox"
|
||||
id="privacy-agree"
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-accent focus:ring-accent bg-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm leading-6">
|
||||
<label htmlFor="privacy-agree" className="font-medium text-foreground select-none cursor-pointer">
|
||||
I have read and agree to the
|
||||
</label>{' '}
|
||||
<a href="/privacy" target="_blank" className="font-semibold text-accent hover:underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
data-testid="privacy-continue-button"
|
||||
type="button"
|
||||
disabled={!agreed}
|
||||
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
||||
font-medium text-background hover:bg-secondary-accent
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out enabled:hover:scale-[1.04]"
|
||||
onClick={handleAccept}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="flex items-start gap-3 rounded-lg border border-line p-3 bg-surface-sunken">
|
||||
<div className="flex h-6 items-center">
|
||||
<input
|
||||
data-testid="privacy-agree-checkbox"
|
||||
id="privacy-agree"
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-line text-accent focus:ring-accent-line bg-surface"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm leading-6">
|
||||
<label htmlFor="privacy-agree" className="font-medium text-foreground select-none cursor-pointer">
|
||||
I have read and agree to the
|
||||
</label>{' '}
|
||||
<a href="/privacy" target="_blank" className="font-semibold text-accent hover:underline">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
data-testid="privacy-continue-button"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
disabled={!agreed}
|
||||
onClick={handleAccept}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -183,67 +137,28 @@ export function showPrivacyModal(): void {
|
|||
};
|
||||
|
||||
return (
|
||||
<Transition
|
||||
appear
|
||||
show={show}
|
||||
as={Fragment}
|
||||
<ModalFrame
|
||||
open={show}
|
||||
onClose={handleClose}
|
||||
afterLeave={() => {
|
||||
root.unmount();
|
||||
container.remove();
|
||||
}}
|
||||
>
|
||||
<Dialog as="div" className="relative z-50" onClose={handleClose}>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
<ModalTitle>Privacy & Data Usage</ModalTitle>
|
||||
|
||||
<PrivacyModalBody origin={origin} />
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
className="text-lg font-semibold leading-6 text-foreground"
|
||||
>
|
||||
Privacy & Data Usage
|
||||
</DialogTitle>
|
||||
|
||||
<PrivacyModalBody origin={origin} />
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
||||
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFrame>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="bg-offbase rounded-lg p-3 space-y-2">
|
||||
<div className="bg-surface-sunken rounded-lg p-3 space-y-2">
|
||||
{/* Header with operation type and cancel button */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
|
|
@ -52,29 +52,27 @@ export function ProgressCard({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className={buttonClass({
|
||||
variant: 'ghost',
|
||||
size: 'xs',
|
||||
className: 'shrink-0 hover:bg-background/50 hover:scale-[1.04]',
|
||||
})}
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={(e) => onCancel(e)}
|
||||
>
|
||||
<span>{cancelText}</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-background rounded-full overflow-hidden h-1.5">
|
||||
<div
|
||||
className="h-full bg-accent transition-all duration-300 ease-out"
|
||||
className="h-full bg-accent transition duration-slow ease-standard"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<div className="flex items-center gap-2 text-xs text-soft">
|
||||
{completedChapters !== undefined && (
|
||||
<>
|
||||
<span className="font-medium">{completedChapters} chapters</span>
|
||||
|
|
|
|||
|
|
@ -32,17 +32,17 @@ export function ProgressPopup({
|
|||
<Transition
|
||||
show={isOpen}
|
||||
as={Fragment}
|
||||
enter="transform transition ease-out duration-300"
|
||||
enter="transform transition ease-standard duration-slow"
|
||||
enterFrom="opacity-0 -translate-y-4"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transform transition ease-in duration-200"
|
||||
leave="transform transition ease-standard duration-base"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 -translate-y-4"
|
||||
>
|
||||
<div className="fixed inset-x-0 top-2 z-[60] pointer-events-none px-4">
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div
|
||||
className={`pointer-events-auto shadow-xl ${
|
||||
className={`pointer-events-auto shadow-elev-3 ${
|
||||
onClick ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={onClick}
|
||||
|
|
|
|||
|
|
@ -2,17 +2,8 @@
|
|||
|
||||
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
Transition,
|
||||
TransitionChild,
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOptions,
|
||||
ListboxOption,
|
||||
Button,
|
||||
Input,
|
||||
} from '@headlessui/react';
|
||||
import Link from 'next/link';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
|
@ -52,14 +43,20 @@ import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
|
|||
import { useSharedProviders } from '@/hooks/useSharedProviders';
|
||||
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
|
||||
import {
|
||||
SidebarNav,
|
||||
SidebarNavItem,
|
||||
SegmentedControl,
|
||||
Button,
|
||||
ChoiceTile,
|
||||
IconButton,
|
||||
Input,
|
||||
ModalFrame,
|
||||
ModalTitle,
|
||||
inputClass,
|
||||
listboxButtonClass,
|
||||
listboxOptionClass,
|
||||
listboxOptionsClass,
|
||||
segmentedButtonClass,
|
||||
segmentedGroupClass,
|
||||
} from '@/components/formPrimitives';
|
||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
||||
SharedListboxButton,
|
||||
SharedListboxOption,
|
||||
SharedListboxOptions,
|
||||
} from '@/components/ui';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { fetchChangelogManifest, fetchChangelogReleaseBody } from '@/lib/client/changelog';
|
||||
|
|
@ -110,6 +107,54 @@ const CUSTOM_COLOR_FIELDS: { key: keyof CustomThemeColors; label: string }[] = [
|
|||
{ key: 'muted', label: 'Muted' },
|
||||
];
|
||||
|
||||
function ThemeSwatches({ colors }: { colors: ThemeColorSet }) {
|
||||
return (
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.background }} />
|
||||
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.offbase }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeChoice({
|
||||
label,
|
||||
colors,
|
||||
selected,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
colors: ThemeColorSet;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<ChoiceTile
|
||||
selected={selected}
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
style={{ backgroundColor: colors.base }}
|
||||
>
|
||||
{selected ? (
|
||||
<CheckIcon className="h-3.5 w-3.5 shrink-0" style={{ color: colors.accent }} />
|
||||
) : (
|
||||
<span className="w-3.5 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-medium w-14 shrink-0"
|
||||
style={{ color: colors.foreground }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<ThemeSwatches colors={colors} />
|
||||
</ChoiceTile>
|
||||
);
|
||||
}
|
||||
|
||||
type SectionId = 'api' | 'theme' | 'docs' | 'account' | 'admin';
|
||||
|
||||
type 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 (
|
||||
<SidebarNavItem
|
||||
compact
|
||||
onClick={onOpen}
|
||||
className={className}
|
||||
aria-label="Settings"
|
||||
icon={<SettingsIcon className="w-3.5 h-3.5" />}
|
||||
label={triggerLabel ?? 'Settings'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onOpen}
|
||||
className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase hover:text-accent transition-transform transition-colors duration-200 ease-out hover:scale-[1.01] ${className}`}
|
||||
className={className}
|
||||
aria-label="Settings"
|
||||
tabIndex={0}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 transition-transform duration-200 ease-out hover:scale-[1.01] hover:rotate-45" />
|
||||
<SettingsIcon className="w-4 h-4 transition-transform duration-base ease-standard hover:rotate-45" />
|
||||
{triggerLabel && <span className="ml-2">{triggerLabel}</span>}
|
||||
</Button>
|
||||
);
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className={`relative ${isChangelogOpen ? 'z-[90]' : 'z-50'}`}
|
||||
onClose={resetToCurrent}
|
||||
>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel data-testid="settings-modal" className="relative w-full max-w-4xl transform rounded-xl bg-base text-left align-middle shadow-xl transition-all overflow-hidden border border-offbase">
|
||||
<ModalFrame
|
||||
open={isOpen}
|
||||
onClose={resetToCurrent}
|
||||
size="xl"
|
||||
panelTestId="settings-modal"
|
||||
className={isChangelogOpen ? 'z-[90]' : 'z-50'}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-offbase">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-line-soft">
|
||||
<div className="flex items-baseline gap-4">
|
||||
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground">
|
||||
Settings
|
||||
</DialogTitle>
|
||||
<ModalTitle>Settings</ModalTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsChangelogOpen(true)}
|
||||
className="text-sm font-medium leading-6 text-muted hover:text-accent transition-colors"
|
||||
className="text-sm font-medium leading-6 text-soft hover:text-accent transition-colors"
|
||||
>
|
||||
{displayVersion ? `v${displayVersion} · Changelog` : 'Changelog'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => showPrivacyModal()}
|
||||
className="text-sm font-medium text-muted hover:text-accent transition-colors"
|
||||
className="text-sm font-medium text-soft hover:text-accent transition-colors"
|
||||
>
|
||||
Privacy
|
||||
</Button>
|
||||
|
|
@ -572,50 +613,42 @@ export function SettingsModal({
|
|||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile: 2x2 grid nav */}
|
||||
<div className="grid grid-cols-2 gap-1 sm:hidden border-b border-offbase bg-background p-2">
|
||||
{/* Mobile nav */}
|
||||
<SidebarNav layout="grid" className="sm:hidden border-b border-line-soft bg-background p-2">
|
||||
{visibleSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<button
|
||||
<SidebarNavItem
|
||||
compact
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
activeSection === section.id
|
||||
? 'bg-accent text-background'
|
||||
: 'text-foreground hover:bg-offbase hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{section.label}
|
||||
</button>
|
||||
active={activeSection === section.id}
|
||||
icon={<Icon className="w-3.5 h-3.5" />}
|
||||
label={section.label}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SidebarNav>
|
||||
|
||||
<div className="flex flex-row h-[490px]">
|
||||
{/* Desktop: vertical sidebar */}
|
||||
<nav className="hidden sm:block w-44 shrink-0 border-r border-offbase bg-background p-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<nav className="hidden sm:block w-44 shrink-0 border-r border-line-soft bg-background p-2">
|
||||
<SidebarNav>
|
||||
{visibleSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
const active = activeSection === section.id;
|
||||
return (
|
||||
<button
|
||||
<SidebarNavItem
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`w-full flex items-center gap-2.5 text-left px-2.5 py-1.5 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
active
|
||||
? 'bg-accent text-background'
|
||||
: 'text-foreground hover:bg-base hover:text-accent'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
{section.label}
|
||||
</button>
|
||||
active={active}
|
||||
icon={<Icon className="w-4 h-4" />}
|
||||
label={section.label}
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SidebarNav>
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
|
|
@ -630,7 +663,7 @@ export function SettingsModal({
|
|||
<div className="space-y-1.5">
|
||||
<label className={fieldLabelClass}>TTS Provider</label>
|
||||
{ttsProviders.length === 0 ? (
|
||||
<p className="text-xs text-amber-500">
|
||||
<p className="text-xs text-accent">
|
||||
User API keys are restricted and no shared provider is configured. Ask an admin to add one.
|
||||
</p>
|
||||
) : (
|
||||
|
|
@ -656,28 +689,26 @@ export function SettingsModal({
|
|||
setCustomModelInput('');
|
||||
}}
|
||||
>
|
||||
<ListboxButton className={listboxButtonClass}>
|
||||
<SharedListboxButton>
|
||||
<span className="block truncate">
|
||||
{selectedProviderOption?.name || 'Select Provider'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-soft" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
<SharedListboxOptions
|
||||
anchor="bottom start"
|
||||
className={listboxOptionsClass}
|
||||
>
|
||||
{ttsProviders.map((provider) => (
|
||||
<ListboxOption
|
||||
<SharedListboxOption
|
||||
key={provider.id}
|
||||
className={({ active }) => listboxOptionClass(active)}
|
||||
value={provider}
|
||||
>
|
||||
{({ selected }) => (
|
||||
|
|
@ -692,15 +723,15 @@ export function SettingsModal({
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
)}
|
||||
</div>
|
||||
{restrictUserApiKeys && (
|
||||
<p className="text-xs text-muted">
|
||||
<p className="text-xs text-soft">
|
||||
This instance restricts user API keys. TTS runs through admin-configured shared providers only.
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -737,7 +768,7 @@ export function SettingsModal({
|
|||
</div>
|
||||
)}
|
||||
{isSharedSelected && (
|
||||
<p className="text-xs text-muted">
|
||||
<p className="text-xs text-soft">
|
||||
This is a shared provider configured by an admin. API key and base URL are managed server-side.
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -745,7 +776,7 @@ export function SettingsModal({
|
|||
<div className="space-y-1.5">
|
||||
<label className={fieldLabelClass}>TTS Model</label>
|
||||
{!showAllProviderModels && (
|
||||
<p className="text-xs text-muted">
|
||||
<p className="text-xs text-soft">
|
||||
This instance restricts model selection to each provider's default model.
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -761,14 +792,14 @@ export function SettingsModal({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<ListboxButton className={listboxButtonClass}>
|
||||
<SharedListboxButton>
|
||||
{selectedModel ? (
|
||||
<span className="block">
|
||||
<span className="block truncate">
|
||||
{selectedModel.name}
|
||||
</span>
|
||||
{selectedModelVersion && (
|
||||
<span className="block truncate text-xs text-muted">
|
||||
<span className="block truncate text-xs text-soft">
|
||||
{selectedModelVersion}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -777,23 +808,21 @@ export function SettingsModal({
|
|||
<span className="block truncate">Select Model</span>
|
||||
)}
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
||||
<ChevronUpDownIcon className="h-5 w-5 text-soft" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions
|
||||
<SharedListboxOptions
|
||||
anchor="bottom start"
|
||||
className={listboxOptionsClass}
|
||||
>
|
||||
{ttsModels.map((model) => (
|
||||
<ListboxOption
|
||||
<SharedListboxOption
|
||||
key={model.id}
|
||||
className={({ active }) => listboxOptionClass(active)}
|
||||
value={model}
|
||||
>
|
||||
{({ selected }) => (
|
||||
|
|
@ -801,7 +830,7 @@ export function SettingsModal({
|
|||
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
<span className="block truncate">{model.name}</span>
|
||||
{model.id.includes(':') && (
|
||||
<span className="block truncate text-xs text-muted">
|
||||
<span className="block truncate text-xs text-soft">
|
||||
{model.id.slice(model.id.indexOf(':'))}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -813,9 +842,9 @@ export function SettingsModal({
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
|
||||
|
|
@ -849,7 +878,8 @@ export function SettingsModal({
|
|||
<div className="pt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className={buttonClass({ variant: 'secondary', size: 'md' })}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
onClick={async () => {
|
||||
const defaults = resolveProviderDefaults({
|
||||
providerRef: runtimeConfig.defaultTtsProvider,
|
||||
|
|
@ -869,7 +899,8 @@ export function SettingsModal({
|
|||
<Button
|
||||
data-testid="settings-save-button"
|
||||
type="button"
|
||||
className={buttonClass({ variant: 'primary', size: 'md' })}
|
||||
variant="primary"
|
||||
size="md"
|
||||
disabled={!canSubmit}
|
||||
onClick={async () => {
|
||||
const defaults = resolveProviderDefaults({
|
||||
|
|
@ -906,88 +937,48 @@ export function SettingsModal({
|
|||
const colors = getThemeColors(systemTheme.id);
|
||||
const isActive = theme === systemTheme.id;
|
||||
return (
|
||||
<button
|
||||
<ThemeChoice
|
||||
label={systemTheme.name}
|
||||
colors={colors}
|
||||
selected={isActive}
|
||||
onClick={() => setTheme(systemTheme.id)}
|
||||
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 w-full text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border
|
||||
${isActive
|
||||
? 'border-accent'
|
||||
: 'border-offbase hover:border-muted'
|
||||
}`}
|
||||
style={{ backgroundColor: colors.base }}
|
||||
>
|
||||
{isActive ? (
|
||||
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
) : (
|
||||
<span className="w-3.5 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-medium w-14 shrink-0"
|
||||
style={{ color: colors.foreground }}
|
||||
>
|
||||
{systemTheme.name}
|
||||
</span>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.background }} />
|
||||
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.offbase }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
|
||||
</div>
|
||||
</button>
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Custom theme */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-muted uppercase tracking-wide">Custom</label>
|
||||
<label className="block text-xs font-medium text-soft uppercase tracking-wide">Custom</label>
|
||||
{(() => {
|
||||
const colors = getThemeColors('custom');
|
||||
const isActive = theme === 'custom';
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
<ThemeChoice
|
||||
label="Custom"
|
||||
colors={colors}
|
||||
selected={isActive}
|
||||
onClick={() => {
|
||||
setTheme('custom');
|
||||
setIsCustomExpanded(true);
|
||||
}}
|
||||
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 flex-1 text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border
|
||||
${isActive
|
||||
? 'border-accent'
|
||||
: 'border-offbase hover:border-muted'
|
||||
}`}
|
||||
style={{ backgroundColor: colors.base }}
|
||||
>
|
||||
{isActive ? (
|
||||
<CheckIcon className="h-3.5 w-3.5 shrink-0" style={{ color: colors.accent }} />
|
||||
) : (
|
||||
<span className="w-3.5 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-medium w-14 shrink-0"
|
||||
style={{ color: colors.foreground }}
|
||||
>
|
||||
Custom
|
||||
</span>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.background }} />
|
||||
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.offbase }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
className="flex-1"
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => setIsCustomExpanded(!isCustomExpanded)}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-offbase hover:border-muted transition-colors"
|
||||
tone="surface"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
style={{ color: colors.muted, backgroundColor: colors.base }}
|
||||
aria-label={isCustomExpanded ? 'Collapse color picker' : 'Expand color picker'}
|
||||
>
|
||||
<svg className={`w-3.5 h-3.5 transition-transform duration-200 ${isCustomExpanded ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className={`w-3.5 h-3.5 transition-transform duration-base ${isCustomExpanded ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{isCustomExpanded && (
|
||||
|
|
@ -1043,41 +1034,19 @@ export function SettingsModal({
|
|||
|
||||
{/* Light themes */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-muted uppercase tracking-wide">Light</label>
|
||||
<label className="block text-xs font-medium text-soft uppercase tracking-wide">Light</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
||||
{lightThemes.map((t) => {
|
||||
const colors = getThemeColors(t.id);
|
||||
const isActive = theme === t.id;
|
||||
return (
|
||||
<button
|
||||
<ThemeChoice
|
||||
key={t.id}
|
||||
label={t.name}
|
||||
colors={colors}
|
||||
selected={isActive}
|
||||
onClick={() => setTheme(t.id)}
|
||||
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border
|
||||
${isActive
|
||||
? 'border-accent'
|
||||
: 'border-offbase hover:border-muted'
|
||||
}`}
|
||||
style={{ backgroundColor: colors.base }}
|
||||
>
|
||||
{isActive ? (
|
||||
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
) : (
|
||||
<span className="w-3.5 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-medium w-14 shrink-0"
|
||||
style={{ color: colors.foreground }}
|
||||
>
|
||||
{t.name}
|
||||
</span>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.background }} />
|
||||
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.offbase }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -1085,41 +1054,19 @@ export function SettingsModal({
|
|||
|
||||
{/* Dark themes */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-muted uppercase tracking-wide">Dark</label>
|
||||
<label className="block text-xs font-medium text-soft uppercase tracking-wide">Dark</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
||||
{darkThemes.map((t) => {
|
||||
const colors = getThemeColors(t.id);
|
||||
const isActive = theme === t.id;
|
||||
return (
|
||||
<button
|
||||
<ThemeChoice
|
||||
key={t.id}
|
||||
label={t.name}
|
||||
colors={colors}
|
||||
selected={isActive}
|
||||
onClick={() => setTheme(t.id)}
|
||||
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border
|
||||
${isActive
|
||||
? 'border-accent'
|
||||
: 'border-offbase hover:border-muted'
|
||||
}`}
|
||||
style={{ backgroundColor: colors.base }}
|
||||
>
|
||||
{isActive ? (
|
||||
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
) : (
|
||||
<span className="w-3.5 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-medium w-14 shrink-0"
|
||||
style={{ color: colors.foreground }}
|
||||
>
|
||||
{t.name}
|
||||
</span>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.background }} />
|
||||
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.offbase }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
|
||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -1135,7 +1082,8 @@ export function SettingsModal({
|
|||
<Button
|
||||
onClick={handleImportLibrary}
|
||||
disabled={isBusy}
|
||||
className={buttonClass({ variant: 'outline', size: 'md' })}
|
||||
variant="outline"
|
||||
size="md"
|
||||
>
|
||||
{isImportingLibrary ? `Importing... ${Math.round(progress)}%` : 'Import from library'}
|
||||
</Button>
|
||||
|
|
@ -1147,14 +1095,16 @@ export function SettingsModal({
|
|||
<Button
|
||||
onClick={handleRefresh}
|
||||
disabled={isBusy}
|
||||
className={buttonClass({ variant: 'outline', size: 'md' })}
|
||||
variant="outline"
|
||||
size="md"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClearCache}
|
||||
disabled={isBusy}
|
||||
className={buttonClass({ variant: 'outline', size: 'md' })}
|
||||
variant="outline"
|
||||
size="md"
|
||||
>
|
||||
Clear cache
|
||||
</Button>
|
||||
|
|
@ -1166,30 +1116,16 @@ export function SettingsModal({
|
|||
{/* Admin Section */}
|
||||
{activeSection === 'admin' && isAdmin && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Admin tab"
|
||||
className={`${segmentedGroupClass} grid-cols-2`}
|
||||
>
|
||||
{([
|
||||
{ id: 'providers', label: 'Shared providers' },
|
||||
{ id: 'features', label: 'Site features' },
|
||||
] as { id: AdminSubTab; label: string }[]).map((tab) => {
|
||||
const active = adminSubTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => setAdminSubTab(tab.id)}
|
||||
className={segmentedButtonClass(active)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={adminSubTab}
|
||||
options={[
|
||||
{ value: 'providers', label: 'Shared providers' },
|
||||
{ value: 'features', label: 'Site features' },
|
||||
]}
|
||||
onChange={setAdminSubTab}
|
||||
ariaLabel="Admin tab"
|
||||
className="grid-cols-2"
|
||||
/>
|
||||
{adminSubTab === 'providers' && <AdminProvidersPanel />}
|
||||
{adminSubTab === 'features' && <AdminFeaturesPanel />}
|
||||
</div>
|
||||
|
|
@ -1199,10 +1135,10 @@ export function SettingsModal({
|
|||
{activeSection === 'account' && (
|
||||
<div className="space-y-2">
|
||||
{/* Session info */}
|
||||
<div className="rounded-lg bg-background border border-offbase p-4 space-y-2">
|
||||
<div className="rounded-lg bg-background border border-line p-4 space-y-2">
|
||||
<h4 className="text-sm font-medium text-foreground">Current Session</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<p className="text-muted">Logged in as:</p>
|
||||
<p className="text-soft">Logged in as:</p>
|
||||
{session?.user ? (
|
||||
<>
|
||||
<p className="font-medium text-foreground">
|
||||
|
|
@ -1211,7 +1147,7 @@ export function SettingsModal({
|
|||
: (session.user.name || session.user.email || 'Account')}
|
||||
</p>
|
||||
{!session.user.isAnonymous && (
|
||||
<p className="text-xs text-muted font-mono">{session.user.email}</p>
|
||||
<p className="text-xs text-soft font-mono">{session.user.email}</p>
|
||||
)}
|
||||
{session.user.isAnonymous && (
|
||||
<p className="text-xs text-accent mt-1">Anonymous session</p>
|
||||
|
|
@ -1225,20 +1161,20 @@ export function SettingsModal({
|
|||
|
||||
{/* Export Data */}
|
||||
{session?.user && (
|
||||
<button
|
||||
<ChoiceTile
|
||||
onClick={() => {
|
||||
window.open('/api/user/export', '_blank');
|
||||
}}
|
||||
className="w-full rounded-lg border border-offbase bg-background p-4 flex items-center gap-4 hover:bg-offbase transition-colors text-left group"
|
||||
className="w-full rounded-lg bg-background p-4 text-left hover:bg-accent-wash"
|
||||
>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-offbase flex items-center justify-center group-hover:bg-background transition-colors">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-surface-sunken flex items-center justify-center">
|
||||
<DownloadIcon className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">Export My Data</p>
|
||||
<p className="text-xs text-muted">Download all your data as a ZIP file</p>
|
||||
<p className="text-xs text-soft">Download all your data as a ZIP file</p>
|
||||
</div>
|
||||
</button>
|
||||
</ChoiceTile>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
|
|
@ -1247,27 +1183,31 @@ export function SettingsModal({
|
|||
<>
|
||||
<Button
|
||||
onClick={handleSignOut}
|
||||
className={buttonClass({ variant: 'outline', size: 'md', className: 'hover:scale-[1.04]' })}
|
||||
variant="outline"
|
||||
size="md"
|
||||
>
|
||||
Disconnect account
|
||||
</Button>
|
||||
|
||||
<div className="pt-4 mt-4 border-t border-offbase">
|
||||
<label className="block text-sm font-medium text-red-500 mb-2">Danger Zone</label>
|
||||
<Button
|
||||
onClick={() => setShowDeleteAccountConfirm(true)}
|
||||
className={buttonClass({ variant: 'danger', size: 'md' })}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
<p className="text-xs text-muted mt-2">
|
||||
Permanently deletes your account and all data.
|
||||
</p>
|
||||
</div>
|
||||
{enableDestructiveDelete && (
|
||||
<div className="pt-4 mt-4 border-t border-line-soft">
|
||||
<label className="block text-sm font-medium text-danger mb-2">Danger Zone</label>
|
||||
<Button
|
||||
onClick={() => setShowDeleteAccountConfirm(true)}
|
||||
variant="danger"
|
||||
size="md"
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
<p className="text-xs text-soft mt-2">
|
||||
Permanently deletes your account and all data.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="pt-2 border-t border-offbase">
|
||||
<p className="text-sm text-muted mb-3">
|
||||
<div className="pt-2 border-t border-line-soft">
|
||||
<p className="text-sm text-soft mb-3">
|
||||
{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({
|
|||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/signin">
|
||||
<Button className={buttonClass({ variant: 'outline', size: 'md', className: 'hover:scale-[1.04]' })}>
|
||||
<Button variant="outline" size="md">
|
||||
Connect
|
||||
</Button>
|
||||
</Link>
|
||||
{runtimeConfig.enableUserSignups && (
|
||||
<Link href="/signup">
|
||||
<Button className={buttonClass({ variant: 'primary', size: 'md', className: 'hover:scale-[1.04]' })}>
|
||||
<Button variant="primary" size="md">
|
||||
Create account
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/?redirect=false">
|
||||
<Button className={buttonClass({ variant: 'outline', size: 'md', className: 'hover:scale-[1.04]' })}>
|
||||
<Button variant="outline" size="md">
|
||||
Back to landing page
|
||||
</Button>
|
||||
</Link>
|
||||
|
|
@ -1304,12 +1244,7 @@ export function SettingsModal({
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</ModalFrame>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteDocsConfirm}
|
||||
|
|
@ -1435,21 +1370,20 @@ function SettingsChangelogPanel({
|
|||
}, [expanded, manifest, manifestUrl, bodies]);
|
||||
|
||||
return (
|
||||
<div className="h-[490px] flex flex-col bg-base">
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-offbase bg-background">
|
||||
<Button
|
||||
<div className="h-[490px] flex flex-col bg-surface">
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-line-soft bg-background">
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center justify-center rounded-md text-muted hover:text-accent hover:bg-base transition-all duration-200 ease-in-out transform hover:scale-[1.01]"
|
||||
aria-label="Back to settings"
|
||||
title="Back"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</Button>
|
||||
</IconButton>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-sm font-semibold text-foreground">Changelog</h4>
|
||||
<p className="text-xs text-muted truncate">
|
||||
<p className="text-xs text-soft truncate">
|
||||
{normalizedAppVersion
|
||||
? `Current version: v${normalizedAppVersion}`
|
||||
: 'Release history from GitHub'}
|
||||
|
|
@ -1459,20 +1393,20 @@ function SettingsChangelogPanel({
|
|||
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-3">
|
||||
{loading && (
|
||||
<div className="py-3 text-sm text-muted">
|
||||
<div className="py-3 text-sm text-soft">
|
||||
Loading changelog…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<div className="py-3 space-y-2 border-b border-offbase">
|
||||
<div className="py-3 space-y-2 border-b border-line-soft">
|
||||
<p className="text-sm text-foreground">Could not load changelog right now.</p>
|
||||
<p className="text-xs text-muted break-words">{error}</p>
|
||||
<p className="text-xs text-soft break-words">{error}</p>
|
||||
<a
|
||||
href="https://github.com/richardr1126/openreader/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex text-xs font-medium text-accent hover:underline transition-all duration-200 ease-in-out transform hover:scale-[1.02]"
|
||||
className="inline-flex text-xs font-medium text-accent hover:underline transition duration-base ease-standard transform"
|
||||
>
|
||||
Open GitHub Releases
|
||||
</a>
|
||||
|
|
@ -1480,7 +1414,7 @@ function SettingsChangelogPanel({
|
|||
)}
|
||||
|
||||
{!loading && !error && manifest.length === 0 && (
|
||||
<div className="py-3 text-sm text-muted">
|
||||
<div className="py-3 text-sm text-soft">
|
||||
No releases found.
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1493,35 +1427,35 @@ function SettingsChangelogPanel({
|
|||
const normalizedName = normalizeVersion(entry.name || '');
|
||||
const showName = Boolean(entry.name) && normalizedName !== normalizedTag;
|
||||
return (
|
||||
<div key={entry.tag_name} className="border-b border-offbase">
|
||||
<div key={entry.tag_name} className="border-b border-line-soft">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((prev) => ({ ...prev, [entry.tag_name]: !isExpanded }))}
|
||||
className="w-full text-left py-2 flex items-center gap-2 hover:bg-base transition-all duration-200 ease-in-out transform hover:scale-[1.01]"
|
||||
className="w-full rounded-md border border-transparent px-2 py-2 text-left flex items-center gap-2 transition duration-base ease-standard hover:border-accent-line hover:bg-accent-wash"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={`w-3.5 h-3.5 shrink-0 text-muted transition-transform ${
|
||||
className={`w-3.5 h-3.5 shrink-0 text-soft transition-transform ${
|
||||
isExpanded ? 'rotate-90 text-foreground' : ''
|
||||
}`}
|
||||
/>
|
||||
<div className="min-w-0 flex items-center gap-2 text-sm w-full">
|
||||
<span className="font-semibold text-foreground shrink-0">{entry.tag_name}</span>
|
||||
{entry.prerelease && (
|
||||
<span className="text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 bg-offbase text-muted shrink-0">
|
||||
<span className="text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 bg-surface-sunken text-soft shrink-0">
|
||||
prerelease
|
||||
</span>
|
||||
)}
|
||||
{isCurrent && (
|
||||
<span className="text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 bg-offbase text-accent shrink-0">
|
||||
<span className="text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 bg-surface-sunken text-accent shrink-0">
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
{showName && (
|
||||
<span className="text-xs text-muted truncate">
|
||||
<span className="text-xs text-soft truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[11px] text-muted shrink-0">
|
||||
<span className="text-[11px] text-soft shrink-0">
|
||||
{new Date(entry.published_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -1530,19 +1464,19 @@ function SettingsChangelogPanel({
|
|||
{isExpanded && (
|
||||
<div className="pl-6 pr-1 pb-3 pt-1 space-y-2">
|
||||
{body ? (
|
||||
<div className="text-sm text-foreground leading-6 space-y-2 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_ul]:pl-5 [&_ol]:pl-5 [&_code]:bg-offbase [&_code]:rounded [&_code]:px-1 [&_pre]:bg-offbase [&_pre]:rounded [&_pre]:p-2 [&_pre]:overflow-x-auto">
|
||||
<div className="text-sm text-foreground leading-6 space-y-2 [&_h1]:text-base [&_h1]:font-semibold [&_h2]:text-sm [&_h2]:font-semibold [&_ul]:pl-5 [&_ol]:pl-5 [&_code]:bg-surface-sunken [&_code]:rounded [&_code]:px-1 [&_pre]:bg-surface-sunken [&_pre]:rounded [&_pre]:p-2 [&_pre]:overflow-x-auto">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{body.body || '_No release notes provided._'}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted">Loading release notes…</p>
|
||||
<p className="text-xs text-soft">Loading release notes…</p>
|
||||
)}
|
||||
<a
|
||||
href={entry.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex text-xs font-medium text-accent hover:underline transition-all duration-200 ease-in-out transform hover:scale-[1.02]"
|
||||
className="inline-flex text-xs font-medium text-accent hover:underline transition duration-base ease-standard transform"
|
||||
>
|
||||
View on GitHub
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
||||
|
|
@ -10,11 +10,11 @@ import {
|
|||
Section,
|
||||
ToggleRow,
|
||||
inputClass,
|
||||
listboxButtonClass,
|
||||
listboxOptionClass,
|
||||
listboxOptionsClass,
|
||||
} from '@/components/formPrimitives';
|
||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
||||
SharedListboxButton,
|
||||
SharedListboxOption,
|
||||
SharedListboxOptions,
|
||||
Button,
|
||||
} from '@/components/ui';
|
||||
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
|
||||
|
||||
|
|
@ -205,24 +205,23 @@ export function AdminFeaturesPanel() {
|
|||
</div>
|
||||
{providerOptions.length > 0 ? (
|
||||
<Listbox value={selectedProviderOption} onChange={handleProviderChange}>
|
||||
<ListboxButton className={listboxButtonClass}>
|
||||
<SharedListboxButton>
|
||||
<span className="block truncate">{selectedProviderOption?.name ?? 'Select provider'}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
|
||||
<SharedListboxOptions anchor="bottom start">
|
||||
{providerOptions.map((opt) => (
|
||||
<ListboxOption
|
||||
<SharedListboxOption
|
||||
key={opt.id}
|
||||
value={opt}
|
||||
className={({ active }) => listboxOptionClass(active)}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
|
|
@ -236,9 +235,9 @@ export function AdminFeaturesPanel() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
) : (
|
||||
|
|
@ -581,14 +580,16 @@ export function AdminFeaturesPanel() {
|
|||
<Button
|
||||
onClick={discardAll}
|
||||
disabled={dirty.size === 0 || saving}
|
||||
className={buttonClass({ variant: 'secondary', size: 'sm' })}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveAll}
|
||||
disabled={dirty.size === 0 || saving}
|
||||
className={buttonClass({ variant: 'primary', size: 'sm' })}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
{saving ? 'Saving…' : dirty.size > 0 ? `Save (${dirty.size})` : 'Save'}
|
||||
</Button>
|
||||
|
|
@ -654,14 +655,16 @@ function SourceBadge({
|
|||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{canReset && !dirty && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={onReset}
|
||||
disabled={saving}
|
||||
className="text-[11px] font-medium text-muted hover:text-accent transition-colors disabled:opacity-50"
|
||||
className="h-auto px-1 py-0 text-[11px] font-medium text-muted hover:text-accent"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{dirty ? (
|
||||
<Badge tone="accent">Modified</Badge>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<Button
|
||||
onClick={startCreate}
|
||||
className={buttonClass({ variant: 'primary', size: 'icon', className: 'h-7 w-7' })}
|
||||
variant="primary"
|
||||
size="icon"
|
||||
aria-label="Add provider"
|
||||
title="Add provider"
|
||||
>
|
||||
|
|
@ -419,24 +424,23 @@ export function AdminProvidersPanel() {
|
|||
setCustomModelInput('');
|
||||
}}
|
||||
>
|
||||
<ListboxButton className={listboxButtonClass}>
|
||||
<SharedListboxButton>
|
||||
<span className="block truncate">{selectedProviderType.label}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
|
||||
<SharedListboxOptions anchor="bottom start">
|
||||
{PROVIDER_TYPE_OPTIONS.map((opt) => (
|
||||
<ListboxOption
|
||||
<SharedListboxOption
|
||||
key={opt.value}
|
||||
value={opt}
|
||||
className={({ active }) => listboxOptionClass(active)}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
|
|
@ -450,9 +454,9 @@ export function AdminProvidersPanel() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
</Field>
|
||||
|
|
@ -478,26 +482,25 @@ export function AdminProvidersPanel() {
|
|||
setCustomModelInput('');
|
||||
}}
|
||||
>
|
||||
<ListboxButton className={listboxButtonClass}>
|
||||
<SharedListboxButton>
|
||||
<span className="block truncate">
|
||||
{selectedModelDefinition?.name ?? 'Select model'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
</SharedListboxButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
|
||||
<SharedListboxOptions anchor="bottom start">
|
||||
{modelDefinitions.map((model) => (
|
||||
<ListboxOption
|
||||
<SharedListboxOption
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
className={({ active }) => listboxOptionClass(active)}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
|
|
@ -516,9 +519,9 @@ export function AdminProvidersPanel() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</SharedListboxOptions>
|
||||
</Transition>
|
||||
</Listbox>
|
||||
{supportsCustomModel && selectedModelId === 'custom' && (
|
||||
|
|
@ -589,13 +592,14 @@ export function AdminProvidersPanel() {
|
|||
/>
|
||||
|
||||
<div className="pt-1 flex justify-end gap-2">
|
||||
<Button onClick={cancelEdit} className={buttonClass({ variant: 'secondary', size: 'sm' })}>
|
||||
<Button onClick={cancelEdit} variant="secondary" size="sm">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
disabled={submitDisabled}
|
||||
className={buttonClass({ variant: 'primary', size: 'sm' })}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
{saveMutation.isPending ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'}
|
||||
</Button>
|
||||
|
|
@ -637,7 +641,9 @@ export function AdminProvidersPanel() {
|
|||
</div>
|
||||
<Menu as="div" className="relative shrink-0">
|
||||
<MenuButton
|
||||
className="inline-flex items-center justify-center h-7 w-7 rounded-md border border-offbase bg-base text-muted hover:text-accent hover:bg-offbase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
as={IconButton}
|
||||
tone="surface"
|
||||
size="sm"
|
||||
title="Provider actions"
|
||||
aria-label="Provider actions"
|
||||
disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending}
|
||||
|
|
@ -653,56 +659,26 @@ export function AdminProvidersPanel() {
|
|||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
<MenuItemsSurface
|
||||
anchor="bottom end"
|
||||
className="z-50 mt-2 min-w-[170px] rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none p-1"
|
||||
className="z-50 mt-2 min-w-[170px] bg-base focus:outline-none"
|
||||
>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEdit(p)}
|
||||
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem disabled={!p.enabled || defaultProviderSlug === p.slug}>
|
||||
{({ active, disabled }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDefault(p.slug)}
|
||||
disabled={disabled}
|
||||
className={`${disabled ? 'text-muted/60 cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
>
|
||||
Set as default
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEnabled(p)}
|
||||
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
>
|
||||
{p.enabled ? 'Disable' : 'Enable'}
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(p.id)}
|
||||
className={`${active ? 'bg-offbase' : ''} text-red-500 flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
<MenuActionItem onClick={() => startEdit(p)}>
|
||||
Edit
|
||||
</MenuActionItem>
|
||||
<MenuActionItem
|
||||
onClick={() => setDefault(p.slug)}
|
||||
disabled={!p.enabled || defaultProviderSlug === p.slug}
|
||||
>
|
||||
Set as default
|
||||
</MenuActionItem>
|
||||
<MenuActionItem onClick={() => toggleEnabled(p)}>
|
||||
{p.enabled ? 'Disable' : 'Enable'}
|
||||
</MenuActionItem>
|
||||
<MenuActionItem tone="danger" onClick={() => remove(p.id)}>
|
||||
Delete
|
||||
</MenuActionItem>
|
||||
</MenuItemsSurface>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
|
@ -252,25 +252,25 @@ export function AuthLoader({ children }: { children: ReactNode }) {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-base z-50 flex flex-col items-center justify-center gap-4">
|
||||
<div className="fixed inset-0 bg-surface z-50 flex flex-col items-center justify-center gap-4">
|
||||
<LoadingSpinner className="w-8 h-8 text-accent" />
|
||||
{bootstrapError ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm text-muted text-center">{bootstrapError}</p>
|
||||
<button
|
||||
type="button"
|
||||
<p className="text-sm text-soft text-center">{bootstrapError}</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
attemptedForNullSessionRef.current = false;
|
||||
setBootstrapError(null);
|
||||
setRetryNonce((v) => v + 1);
|
||||
}}
|
||||
className={buttonClass({ variant: 'primary', size: 'sm' })}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted animate-pulse">
|
||||
<p className="text-sm text-soft animate-pulse">
|
||||
{isAutoLoggingIn ? 'Starting anonymous session...' : 'Loading...'}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-[80]" onClose={onDismiss}>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
<ModalFrame open={isOpen} onClose={onDismiss} panelTestId="claim-modal" className="z-[80]">
|
||||
<ModalTitle className="mb-4">Existing Data Found</ModalTitle>
|
||||
|
||||
<p className="text-sm text-soft mb-2">
|
||||
We found existing anonymous data from before auth was enabled.
|
||||
Claim it now to attach it to your account.
|
||||
</p>
|
||||
|
||||
<div className="mb-4 rounded-lg border border-line bg-surface-sunken p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-soft">Claimable data</div>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-soft">
|
||||
<li>{claimableCounts.documents} document(s)</li>
|
||||
<li>{claimableCounts.audiobooks} audiobook(s)</li>
|
||||
<li>{claimableCounts.preferences} preference set(s)</li>
|
||||
<li>{claimableCounts.progress} reading progress record(s)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-faint mb-6 italic">
|
||||
⚠️ First user to claim this data will own it and revoke access for anyone else.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
data-testid="claim-dismiss-button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
disabled={isClaiming}
|
||||
>
|
||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel data-testid="claim-modal" className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
className="text-lg font-semibold leading-6 text-foreground mb-4"
|
||||
>
|
||||
Existing Data Found
|
||||
</DialogTitle>
|
||||
|
||||
<p className="text-sm text-muted mb-2">
|
||||
We found existing anonymous data from before auth was enabled.
|
||||
Claim it now to attach it to your account.
|
||||
</p>
|
||||
|
||||
<div className="mb-4 rounded-lg border border-offbase bg-offbase/40 p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Claimable data</div>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-foreground/90">
|
||||
<li>{claimableCounts.documents} document(s)</li>
|
||||
<li>{claimableCounts.audiobooks} audiobook(s)</li>
|
||||
<li>{claimableCounts.preferences} preference set(s)</li>
|
||||
<li>{claimableCounts.progress} reading progress record(s)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted/70 mb-6 italic">
|
||||
⚠️ First user to claim this data will own it and revoke access for anyone else.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
data-testid="claim-dismiss-button"
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
disabled={isClaiming}
|
||||
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||
font-medium text-foreground hover:bg-offbase focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||
disabled:opacity-50"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="claim-submit-button"
|
||||
type="button"
|
||||
onClick={handleClaim}
|
||||
disabled={isClaiming}
|
||||
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background
|
||||
disabled:opacity-50"
|
||||
>
|
||||
{isClaiming ? 'Claiming...' : 'Claim Data'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="claim-submit-button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleClaim}
|
||||
disabled={isClaiming}
|
||||
>
|
||||
{isClaiming ? 'Claiming...' : 'Claim Data'}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFrame>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,13 +19,13 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
|
|||
const isAnonymous = status.userType === 'anonymous';
|
||||
|
||||
return (
|
||||
<div className={`bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2 ${className}`}>
|
||||
<div className={`bg-accent-wash border border-accent-line rounded-lg px-3 py-2 ${className}`}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<div className="text-xs sm:text-sm">
|
||||
<span className="font-medium text-amber-700 dark:text-amber-400">
|
||||
<span className="font-medium text-accent ">
|
||||
Daily TTS limit reached.
|
||||
</span>
|
||||
<span className="text-amber-600 dark:text-amber-500 ml-1.5">
|
||||
<span className="text-accent ml-1.5">
|
||||
{`Used ${formatCharCount(status.currentCount)} / ${formatCharCount(status.limit)} characters.`}
|
||||
{' Resets in '}{timeUntilReset}.
|
||||
</span>
|
||||
|
|
@ -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
|
||||
</Link>
|
||||
|
|
@ -64,7 +64,7 @@ export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) {
|
|||
|
||||
if (isAtLimit) {
|
||||
return (
|
||||
<span className={`text-xs font-medium text-amber-600 dark:text-amber-400 ${className}`}>
|
||||
<span className={`text-xs font-medium text-accent ${className}`}>
|
||||
Limit reached
|
||||
</span>
|
||||
);
|
||||
|
|
@ -72,7 +72,7 @@ export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) {
|
|||
|
||||
if (isWarning) {
|
||||
return (
|
||||
<span className={`text-xs text-muted ${className}`}>
|
||||
<span className={`text-xs text-soft ${className}`}>
|
||||
{formatCharCount(status.remainingChars)} chars left
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={`flex w-full flex-col gap-0.5 ${className}`}>
|
||||
<Link href="/signin" className={rowClass}>
|
||||
<UserIcon className="h-3.5 w-3.5 text-muted" />
|
||||
<span className="truncate">Connect</span>
|
||||
<Link href="/signin" legacyBehavior passHref>
|
||||
<SidebarNavLink
|
||||
compact
|
||||
icon={<UserIcon className="h-3.5 w-3.5" />}
|
||||
label="Connect"
|
||||
/>
|
||||
</Link>
|
||||
{enableUserSignups && (
|
||||
<Link href="/signup" className={rowClass}>
|
||||
<UserIcon className="h-3.5 w-3.5 text-muted" />
|
||||
<span className="truncate">Create account</span>
|
||||
<Link href="/signup" legacyBehavior passHref>
|
||||
<SidebarNavLink
|
||||
compact
|
||||
icon={<UserIcon className="h-3.5 w-3.5" />}
|
||||
label="Create account"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -54,17 +57,13 @@ export function UserMenu({
|
|||
|
||||
return (
|
||||
<div className={`flex gap-2 ${className}`}>
|
||||
<Link href="/signin">
|
||||
<Button className="inline-flex items-center rounded-md bg-base border border-offbase px-2 py-1 text-xs font-medium text-foreground hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.01] hover:text-accent">
|
||||
Connect
|
||||
</Button>
|
||||
</Link>
|
||||
<ButtonLink href="/signin" variant="secondary" size="sm">
|
||||
Connect
|
||||
</ButtonLink>
|
||||
{enableUserSignups && (
|
||||
<Link href="/signup">
|
||||
<Button className="inline-flex items-center rounded-md bg-accent px-2 py-1 text-xs font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.01]">
|
||||
Create account
|
||||
</Button>
|
||||
</Link>
|
||||
<ButtonLink href="/signup" variant="primary" size="sm">
|
||||
Create account
|
||||
</ButtonLink>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -72,40 +71,44 @@ export function UserMenu({
|
|||
|
||||
if (variant === 'sidebar') {
|
||||
return (
|
||||
<button
|
||||
<SidebarNavItem
|
||||
compact
|
||||
onClick={handleDisconnectAccount}
|
||||
className={`${rowClass} ${className}`}
|
||||
className={className}
|
||||
title="Disconnect account"
|
||||
aria-label="Disconnect account"
|
||||
>
|
||||
<UserIcon className="h-3.5 w-3.5 text-muted" />
|
||||
<span className="truncate flex-1">{session.user.email || 'Account'}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
icon={<UserIcon className="h-3.5 w-3.5" />}
|
||||
label={session.user.email || 'Account'}
|
||||
trailing={(
|
||||
<svg className="h-3.5 w-3.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 px-2 py-1 rounded-md border border-offbase bg-base ${className}`}>
|
||||
<span className="hidden sm:block text-xs font-medium text-foreground truncate max-w-[160px]">
|
||||
<div className={`flex h-7 items-center gap-1.5 rounded-md border border-line bg-surface px-2 ${className}`}>
|
||||
<span className="hidden max-w-[150px] truncate text-[11px] font-medium text-foreground sm:block">
|
||||
{session.user.email || 'Account'}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
<IconButton
|
||||
onClick={handleDisconnectAccount}
|
||||
className="inline-flex items-center text-foreground text-xs hover:text-accent transform transition-all duration-200 ease-in-out hover:scale-[1.01]"
|
||||
title="Disconnect account"
|
||||
aria-label="Disconnect account"
|
||||
size="xs"
|
||||
className="-mr-0.5"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
</Button>
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogTitle as="h3" className="text-lg font-semibold text-foreground">
|
||||
Create New Folder
|
||||
</DialogTitle>
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
type="text"
|
||||
value={folderName}
|
||||
onChange={(e) => onFolderNameChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Enter folder name"
|
||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted">Press Enter to create or Escape to cancel</p>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
<ModalFrame open={isOpen} onClose={onClose}>
|
||||
<ModalTitle>Create New Folder</ModalTitle>
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
type="text"
|
||||
value={folderName}
|
||||
onChange={(e) => onFolderNameChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Enter folder name"
|
||||
controlSize="lg"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="mt-2 text-xs text-soft">Press Enter to create or Escape to cancel</p>
|
||||
</div>
|
||||
</ModalFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="rounded-md border border-offbase bg-offbase/60 px-2 py-1.5">
|
||||
<div className="rounded-md border border-line bg-surface-sunken px-2 py-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex items-center gap-1.5 text-[11px] leading-tight">
|
||||
<span className="font-medium text-foreground">{label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted">{completedFiles}/{totalFiles}</span>
|
||||
<span className="shrink-0 tabular-nums text-soft">{completedFiles}/{totalFiles}</span>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-1 text-accent" aria-label={`Upload progress ${progress}%`}>
|
||||
<span className="text-[10px] tabular-nums text-muted">{progress}%</span>
|
||||
<span className="text-[10px] tabular-nums text-soft">{progress}%</span>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className={phase === 'converting' ? 'animate-spin' : ''}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
|
|
@ -168,13 +169,13 @@ function SidebarUploadLoader({
|
|||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
style={{ transition: 'stroke-dashoffset 200ms ease-out' }}
|
||||
style={{ transition: 'stroke-dashoffset 200ms ease-standard' }}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{currentFileName && (
|
||||
<p className="mt-0.5 truncate text-[10px] text-muted" title={currentFileName}>
|
||||
<p className="mt-0.5 truncate text-[10px] text-soft" title={currentFileName}>
|
||||
{currentFileName}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -182,6 +183,16 @@ function SidebarUploadLoader({
|
|||
);
|
||||
}
|
||||
|
||||
function DocumentListStateLoader() {
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full min-h-0 bg-surface-sunken animate-pulse"
|
||||
aria-label="Loading documents"
|
||||
aria-busy="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||
const cachedState = cachedDocumentListState;
|
||||
const [sortBy, setSortBy] = useState<SortBy>(cachedState?.sortBy ?? DEFAULT_STATE.sortBy);
|
||||
|
|
@ -649,28 +660,32 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
|||
}}
|
||||
>
|
||||
{!isLoading && showHint && allDocuments.length > 1 && (
|
||||
<div className="px-3 pt-3 shrink-0 bg-background">
|
||||
<div className="flex items-center justify-between bg-base border border-offbase rounded-md px-3 py-1 text-[12px]">
|
||||
<div className="px-3 pt-3 shrink-0 bg-surface-sunken">
|
||||
<div className="flex items-center justify-between bg-surface border border-line rounded-md px-3 py-1 text-[12px]">
|
||||
<p className="text-foreground">
|
||||
Drag files onto each other to make folders. Drop into the sidebar to move.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
onClick={() => setShowHint(false)}
|
||||
className="h-6 w-6 inline-flex items-center justify-center text-muted hover:text-accent hover:bg-base hover:scale-[1.01] rounded transition-all duration-200 ease-out"
|
||||
size="xs"
|
||||
className="h-6 w-6"
|
||||
aria-label="Dismiss hint"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<DocumentListSkeleton viewMode={fallbackViewMode} iconSize={iconSize} />
|
||||
{isInitialized ? (
|
||||
<DocumentListSkeleton viewMode={fallbackViewMode} iconSize={iconSize} />
|
||||
) : (
|
||||
<DocumentListStateLoader />
|
||||
)}
|
||||
</div>
|
||||
) : allDocuments.length === 0 ? (
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ function IconsSkeleton({ iconSize }: { iconSize: IconSize }) {
|
|||
<div className="h-full min-h-0 overflow-y-auto p-3">
|
||||
<div className="grid" style={iconsGridStyle(iconSize, ICON_SKELETON_ITEM_COUNT)}>
|
||||
{Array.from({ length: ICON_SKELETON_ITEM_COUNT }).map((_, index) => (
|
||||
<div key={index} className="overflow-hidden rounded-md border border-offbase bg-base">
|
||||
<div className="aspect-[3/4] w-full bg-offbase" />
|
||||
<div key={index} className="overflow-hidden rounded-md border border-line bg-surface">
|
||||
<div className="aspect-[3/4] w-full bg-surface-sunken" />
|
||||
<div className="flex items-center gap-2 px-2 py-2">
|
||||
<div className="h-3.5 w-3.5 rounded bg-offbase" />
|
||||
<div className="h-2.5 w-4/5 rounded bg-offbase" />
|
||||
<div className="h-3.5 w-3.5 rounded bg-surface-sunken" />
|
||||
<div className="h-2.5 w-4/5 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -31,18 +31,18 @@ function IconsSkeleton({ iconSize }: { iconSize: IconSize }) {
|
|||
function ListSkeleton() {
|
||||
return (
|
||||
<div className="h-full min-h-0 overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 bg-base border-b border-offbase grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px]">
|
||||
<div className="sticky top-0 z-10 bg-surface border-b border-line-soft grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px]">
|
||||
<div className="h-8 flex items-center px-2">
|
||||
<div className="h-2.5 w-12 rounded bg-offbase" />
|
||||
<div className="h-2.5 w-12 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
<div className="h-8 flex items-center px-2">
|
||||
<div className="h-2.5 w-10 rounded bg-offbase" />
|
||||
<div className="h-2.5 w-10 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
<div className="h-8 flex items-center justify-end px-2">
|
||||
<div className="h-2.5 w-10 rounded bg-offbase" />
|
||||
<div className="h-2.5 w-10 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
<div className="h-8 flex items-center px-2">
|
||||
<div className="h-2.5 w-14 rounded bg-offbase" />
|
||||
<div className="h-2.5 w-14 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
|
|
@ -50,23 +50,23 @@ function ListSkeleton() {
|
|||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px] items-center border-b border-offbase h-[35px]"
|
||||
className="grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px] items-center border-b border-line-soft h-[35px]"
|
||||
>
|
||||
<div className="px-2 flex items-center gap-2">
|
||||
<div className="h-3.5 w-3.5 rounded bg-offbase" />
|
||||
<div className="h-2.5 w-2/3 rounded bg-offbase" />
|
||||
<div className="h-3.5 w-3.5 rounded bg-surface-sunken" />
|
||||
<div className="h-2.5 w-2/3 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="h-2.5 w-8 rounded bg-offbase" />
|
||||
<div className="h-2.5 w-8 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
<div className="px-2 flex justify-end">
|
||||
<div className="h-2.5 w-12 rounded bg-offbase" />
|
||||
<div className="h-2.5 w-12 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<div className="h-2.5 w-16 rounded bg-offbase" />
|
||||
<div className="h-2.5 w-16 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
<div className="px-2 flex justify-center">
|
||||
<div className="h-4 w-4 rounded bg-offbase" />
|
||||
<div className="h-4 w-4 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -78,24 +78,24 @@ function ListSkeleton() {
|
|||
function GallerySkeleton() {
|
||||
return (
|
||||
<div className="h-full min-h-0 flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-6 bg-background">
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-6 bg-surface-sunken">
|
||||
<div className="flex flex-col items-center gap-3 max-w-[420px]">
|
||||
<div className="w-[260px] sm:w-[320px] aspect-[3/4] rounded-lg border border-offbase bg-offbase" />
|
||||
<div className="h-3 w-40 rounded bg-offbase" />
|
||||
<div className="h-2.5 w-28 rounded bg-offbase" />
|
||||
<div className="w-[260px] sm:w-[320px] aspect-[3/4] rounded-lg border border-line bg-surface-sunken" />
|
||||
<div className="h-3 w-40 rounded bg-surface-sunken" />
|
||||
<div className="h-2.5 w-28 rounded bg-surface-sunken" />
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-20 rounded-md bg-offbase" />
|
||||
<div className="h-8 w-20 rounded-md bg-offbase" />
|
||||
<div className="h-8 w-20 rounded-md bg-surface-sunken" />
|
||||
<div className="h-8 w-20 rounded-md bg-surface-sunken" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 border-t border-offbase bg-base">
|
||||
<div className="shrink-0 border-t border-line-soft bg-surface">
|
||||
<div className="flex gap-2 overflow-x-auto p-2">
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<div key={index} className="shrink-0 w-[88px] rounded-md overflow-hidden border border-offbase bg-base">
|
||||
<div className="aspect-[3/4] bg-offbase" />
|
||||
<div key={index} className="shrink-0 w-[88px] rounded-md overflow-hidden border border-line bg-surface">
|
||||
<div className="aspect-[3/4] bg-surface-sunken" />
|
||||
<div className="p-1.5">
|
||||
<div className="h-2.5 w-5/6 rounded bg-offbase" />
|
||||
<div className="h-2.5 w-5/6 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
|
|||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full aspect-[3/4] overflow-hidden rounded-t-md bg-base"
|
||||
className="relative w-full aspect-[3/4] overflow-hidden rounded-t-md bg-surface"
|
||||
>
|
||||
{imagePreview ? (
|
||||
<>
|
||||
|
|
@ -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 ? (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-50 to-slate-200" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-surface-solid to-surface-sunken" />
|
||||
<div className="absolute inset-0 opacity-[0.06] bg-[radial-gradient(circle_at_1px_1px,rgba(0,0,0,1)_1px,transparent_0)] [background-size:12px_12px]" />
|
||||
<div className="relative z-10 h-full w-full p-2 flex flex-col">
|
||||
<div className="mt-auto rounded-md bg-white/70 backdrop-blur-[1px] shadow-sm ring-1 ring-black/5 p-2.5 max-h-[70%] overflow-hidden">
|
||||
<div className="mt-auto rounded-md bg-surface-solid backdrop-blur-[1px] shadow-elev-1 ring-1 ring-line-soft p-2.5 max-h-[70%] overflow-hidden">
|
||||
{isTxtFile ? (
|
||||
<pre className="text-[10px] sm:text-[11px] leading-snug text-slate-900 whitespace-pre-wrap font-mono">
|
||||
<pre className="text-[10px] sm:text-[11px] leading-snug text-foreground whitespace-pre-wrap font-mono">
|
||||
{textPreview}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-[10px] sm:text-[11px] leading-snug text-slate-900 break-words [overflow-wrap:anywhere]">
|
||||
<div className="text-[10px] sm:text-[11px] leading-snug text-foreground break-words [overflow-wrap:anywhere]">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
|
|
@ -311,11 +311,11 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) {
|
|||
a: ({ children }) => <span>{children}</span>,
|
||||
img: () => null,
|
||||
blockquote: (props) => (
|
||||
<blockquote className="m-0 pl-2 border-l-2 border-slate-300 text-slate-700" {...props} />
|
||||
<blockquote className="m-0 pl-2 border-l-2 border-line text-soft" {...props} />
|
||||
),
|
||||
code: (props) => (
|
||||
<code
|
||||
className="font-mono text-[10px] bg-slate-900/5 rounded px-1 whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||||
className="font-mono text-[10px] bg-surface-sunken rounded px-1 whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
>
|
||||
<span className="flex-shrink-0 flex items-center">
|
||||
{doc.type === 'pdf' ? (
|
||||
<PDFIcon className={`${FILE_ICON_CLASSES[iconSize]} text-red-500`} />
|
||||
<PDFIcon className={`${FILE_ICON_CLASSES[iconSize]} text-danger`} />
|
||||
) : doc.type === 'epub' ? (
|
||||
<EPUBIcon className={`${FILE_ICON_CLASSES[iconSize]} text-blue-500`} />
|
||||
<EPUBIcon className={`${FILE_ICON_CLASSES[iconSize]} text-accent`} />
|
||||
) : (
|
||||
<FileIcon className={`${FILE_ICON_CLASSES[iconSize]} text-muted`} />
|
||||
<FileIcon className={`${FILE_ICON_CLASSES[iconSize]} text-soft`} />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
|
|
@ -185,9 +185,10 @@ export function DocumentTile({
|
|||
</span>
|
||||
</Link>
|
||||
{showDeleteButton && (
|
||||
<Button
|
||||
<IconButton
|
||||
onClick={() => onDelete(doc)}
|
||||
className={`inline-flex items-center justify-center text-muted hover:text-accent hover:bg-base focus:outline-none transition-colors duration-200 ${TRASH_BTN_CLASSES[iconSize]}`}
|
||||
size="xs"
|
||||
className={TRASH_BTN_CLASSES[iconSize]}
|
||||
aria-label={`Delete ${doc.name}`}
|
||||
>
|
||||
<svg className={TRASH_ICON_CLASSES[iconSize]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -198,7 +199,7 @@ export function DocumentTile({
|
|||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 <PDFIcon className={className ?? 'w-4 h-4 text-red-500'} />;
|
||||
if (doc.type === 'epub') return <EPUBIcon className={className ?? 'w-4 h-4 text-blue-500'} />;
|
||||
return <FileIcon className={className ?? 'w-4 h-4 text-muted'} />;
|
||||
if (doc.type === 'pdf') return <PDFIcon className={className ?? 'w-4 h-4 text-danger'} />;
|
||||
if (doc.type === 'epub') return <EPUBIcon className={className ?? 'w-4 h-4 text-accent'} />;
|
||||
return <FileIcon className={className ?? 'w-4 h-4 text-soft'} />;
|
||||
}
|
||||
|
||||
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}
|
||||
>
|
||||
<div className="aspect-[3/4] bg-base">
|
||||
<div className="aspect-[3/4] bg-surface">
|
||||
<DocumentPreview doc={doc} />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
'px-2 py-1.5 flex items-center gap-1.5 border-t transition-colors duration-200 ' +
|
||||
(active ? 'bg-offbase border-accent' : 'bg-base border-offbase')
|
||||
'px-2 py-1.5 flex items-center gap-1.5 border-t transition-colors duration-base ' +
|
||||
(active ? 'bg-surface-sunken border-accent-line' : 'bg-surface border-line')
|
||||
}
|
||||
>
|
||||
<KindIcon
|
||||
doc={doc}
|
||||
className={
|
||||
'w-3 h-3 shrink-0 transition-colors duration-200 ' +
|
||||
(active ? 'text-accent' : 'text-muted')
|
||||
'w-3 h-3 shrink-0 transition-colors duration-base ' +
|
||||
(active ? 'text-accent' : 'text-soft')
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
'truncate text-[10px] leading-none transition-colors duration-200 ' +
|
||||
'truncate text-[10px] leading-none transition-colors duration-base ' +
|
||||
(active ? 'text-accent font-medium' : 'text-foreground')
|
||||
}
|
||||
>
|
||||
|
|
@ -181,51 +180,48 @@ export function GalleryView({
|
|||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto bg-background">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto bg-surface-sunken">
|
||||
<div className="min-h-full flex items-center justify-center p-3 sm:p-6">
|
||||
{activeDoc ? (
|
||||
<div className="w-full max-w-[920px] flex flex-col md:flex-row items-center md:items-start justify-center gap-4 md:gap-6">
|
||||
<div className="flex flex-col items-center gap-3 w-[180px] sm:w-[260px] md:w-[320px] shrink-0">
|
||||
<div className="w-full aspect-[3/4] rounded-lg overflow-hidden border border-offbase shadow-lg">
|
||||
<div className="w-full aspect-[3/4] rounded-lg overflow-hidden border border-line shadow-elev-2">
|
||||
<DocumentPreview doc={activeDoc} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h2 className="text-[14px] font-semibold text-foreground truncate max-w-[320px]">
|
||||
{activeDoc.name}
|
||||
</h2>
|
||||
<p className="text-[11px] text-muted">
|
||||
<p className="text-[11px] text-soft">
|
||||
{activeDoc.type.toUpperCase()} • {formatDocumentSize(activeDoc.size)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={openHref || '/app'}
|
||||
prefetch={false}
|
||||
className={buttonClass({ variant: 'primary', size: 'sm' })}
|
||||
>
|
||||
<ButtonLink href={openHref || '/app'} prefetch={false} variant="primary" size="sm">
|
||||
Open
|
||||
</Link>
|
||||
<button
|
||||
</ButtonLink>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onDeleteDoc(activeDoc)}
|
||||
className={buttonClass({ variant: 'secondary', size: 'sm' })}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="w-full max-w-[280px] sm:max-w-[360px] md:max-w-[340px] grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md border border-offbase bg-base px-3 py-2 text-[11px] md:self-center">
|
||||
<dt className="text-muted">Type</dt>
|
||||
<dl className="w-full max-w-[280px] sm:max-w-[360px] md:max-w-[340px] grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md border border-line bg-surface px-3 py-2 text-[11px] md:self-center">
|
||||
<dt className="text-soft">Type</dt>
|
||||
<dd className="text-foreground text-right uppercase tracking-wide">{activeDoc.type}</dd>
|
||||
<dt className="text-muted">Size</dt>
|
||||
<dt className="text-soft">Size</dt>
|
||||
<dd className="text-foreground text-right tabular-nums">{formatDocumentSize(activeDoc.size)}</dd>
|
||||
<dt className="text-muted">Last opened</dt>
|
||||
<dt className="text-soft">Last opened</dt>
|
||||
<dd className="text-foreground text-right">{formatDateTime(activeDoc.recentlyOpenedAt)}</dd>
|
||||
<dt className="text-muted">Last modified</dt>
|
||||
<dt className="text-soft">Last modified</dt>
|
||||
<dd className="text-foreground text-right">{formatDateTime(activeDoc.lastModified)}</dd>
|
||||
{activeDoc.folderId && (
|
||||
<>
|
||||
<dt className="text-muted">Folder</dt>
|
||||
<dt className="text-soft">Folder</dt>
|
||||
<dd className="text-foreground text-right truncate" title={folderNameById?.[activeDoc.folderId] ?? activeDoc.folderId}>
|
||||
{folderNameById?.[activeDoc.folderId] ?? activeDoc.folderId}
|
||||
</dd>
|
||||
|
|
@ -233,19 +229,19 @@ export function GalleryView({
|
|||
)}
|
||||
{activeDoc.type === 'pdf' && (
|
||||
<>
|
||||
<dt className="text-muted">Parse status</dt>
|
||||
<dt className="text-soft">Parse status</dt>
|
||||
<dd className="text-foreground text-right">{formatParseStatus(activeDoc.parseStatus)}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12px] text-muted">No documents to show</p>
|
||||
<p className="text-[12px] text-soft">No documents to show</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-offbase bg-gradient-to-b from-base to-offbase/30">
|
||||
<div className="shrink-0 border-t border-line-soft bg-gradient-to-b from-surface to-surface-sunken">
|
||||
<div
|
||||
ref={railRef}
|
||||
className="flex gap-2.5 overflow-x-auto pl-4 pr-3 pt-2.5 pb-1.5 snap-x snap-proximity scroll-pl-4 sm:scroll-pl-5 scroll-pr-3 sm:scroll-pr-4"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import Link from 'next/link';
|
||||
import { useEffect } from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { Button } from '@headlessui/react';
|
||||
import type {
|
||||
DocumentListDocument,
|
||||
SortBy,
|
||||
|
|
@ -11,6 +10,7 @@ import type {
|
|||
} from '@/types/documents';
|
||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||
import { formatDocumentSize } from '@/components/doclist/formatSize';
|
||||
import { IconButton } from '@/components/ui';
|
||||
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
|
||||
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
|
||||
|
||||
|
|
@ -29,9 +29,9 @@ function formatDate(ms: number): string {
|
|||
}
|
||||
|
||||
function KindIcon({ doc }: { doc: DocumentListDocument }) {
|
||||
if (doc.type === 'pdf') return <PDFIcon className="w-4 h-4 shrink-0 text-red-500" />;
|
||||
if (doc.type === 'epub') return <EPUBIcon className="w-4 h-4 shrink-0 text-blue-500" />;
|
||||
return <FileIcon className="w-4 h-4 shrink-0 text-muted" />;
|
||||
if (doc.type === 'pdf') return <PDFIcon className="w-4 h-4 shrink-0 text-danger" />;
|
||||
if (doc.type === 'epub') return <EPUBIcon className="w-4 h-4 shrink-0 text-accent" />;
|
||||
return <FileIcon className="w-4 h-4 shrink-0 text-soft" />;
|
||||
}
|
||||
|
||||
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({
|
|||
<KindIcon doc={doc} />
|
||||
<span className="truncate">{doc.name}</span>
|
||||
</Link>
|
||||
<span className="px-2 text-[11px] text-muted uppercase tracking-wide">{doc.type}</span>
|
||||
<span className="px-2 text-[11px] text-muted text-right tabular-nums">
|
||||
<span className="px-2 text-[11px] text-soft uppercase tracking-wide">{doc.type}</span>
|
||||
<span className="px-2 text-[11px] text-soft text-right tabular-nums">
|
||||
{formatDocumentSize(doc.size)}
|
||||
</span>
|
||||
<span className="px-2 text-[11px] text-muted tabular-nums">
|
||||
<span className="px-2 text-[11px] text-soft tabular-nums">
|
||||
{formatDate(doc.lastModified)}
|
||||
</span>
|
||||
<Button
|
||||
<IconButton
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDeleteDoc(doc);
|
||||
}}
|
||||
className="h-7 w-7 flex items-center justify-center text-muted hover:text-accent hover:bg-offbase hover:scale-[1.02] rounded transition-all duration-200 ease-out"
|
||||
size="sm"
|
||||
aria-label={`Delete ${doc.name}`}
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -172,7 +172,7 @@ function DocRow({
|
|||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -198,7 +198,7 @@ export function ListView({
|
|||
|
||||
return (
|
||||
<div onClick={handleBackgroundClick} className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 bg-base border-b border-offbase grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px]">
|
||||
<div className="sticky top-0 z-10 bg-surface border-b border-line-soft grid grid-cols-[minmax(0,1fr)_44px_72px_104px_28px] sm:grid-cols-[minmax(0,1fr)_56px_96px_140px_32px]">
|
||||
<HeaderCell label="Name" field="name" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} />
|
||||
<HeaderCell label="Kind" field="type" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} />
|
||||
<HeaderCell label="Size" field="size" sortBy={sortBy} sortDirection={sortDirection} onSortChange={onSortChange} align="right" />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={
|
||||
'group w-full flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border transform transition-all duration-200 ease-out text-left hover:scale-[1.01] ' +
|
||||
(active
|
||||
? 'border-accent bg-offbase text-accent'
|
||||
: 'border-transparent bg-transparent text-foreground hover:border-accent hover:text-accent') +
|
||||
(isDropTarget ? ' ring-1 ring-accent' : '')
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
'w-4 h-4 shrink-0 flex items-center justify-center transition-colors duration-200 ' +
|
||||
(active ? 'text-accent' : 'text-muted group-hover:text-accent')
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="truncate flex-1">{label}</span>
|
||||
{typeof count === 'number' && count > 0 && (
|
||||
<span
|
||||
className={`text-[10px] text-muted tabular-nums transition-transform duration-200 ease-out ${countClassName ?? ''}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{trailing}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderRow({
|
||||
folder,
|
||||
active,
|
||||
|
|
@ -122,7 +69,8 @@ function FolderRow({
|
|||
ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
|
||||
className="group/folder relative"
|
||||
>
|
||||
<SidebarRow
|
||||
<SidebarNavItem
|
||||
compact
|
||||
active={active}
|
||||
onClick={onClick}
|
||||
icon={<FolderIcon className="w-3.5 h-3.5" />}
|
||||
|
|
@ -131,42 +79,20 @@ function FolderRow({
|
|||
countClassName="group-hover/folder:-translate-x-6 group-focus-within/folder:-translate-x-6"
|
||||
isDropTarget={isDropTarget}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-5 w-5 inline-flex items-center justify-center rounded text-muted opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100 hover:text-accent hover:bg-offbase transition"
|
||||
size="xs"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100"
|
||||
aria-label={`Delete ${folder.name}`}
|
||||
title={`Delete ${folder.name}`}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
children,
|
||||
isFirst,
|
||||
rightSlot,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
isFirst?: boolean;
|
||||
rightSlot?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'px-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-muted font-semibold leading-none flex items-center justify-between ' +
|
||||
(isFirst ? 'pt-1.5' : 'pt-3')
|
||||
}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{rightSlot && <span className="inline-flex items-center leading-none shrink-0">{rightSlot}</span>}
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -204,19 +130,20 @@ export function FinderSidebar({
|
|||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
<SidebarShell
|
||||
style={{ '--sidebar-width': `${width}px` } as CSSProperties}
|
||||
className="relative h-full w-full md:[width:var(--sidebar-width)] bg-base border-r border-offbase shrink-0 flex flex-col"
|
||||
className="relative h-full w-full md:[width:var(--sidebar-width)] rounded-none border-y-0 border-l-0 border-r border-line-soft shadow-none shrink-0 flex flex-col"
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="p-2 flex flex-col gap-0.5">
|
||||
<SidebarNav className="p-2">
|
||||
{topSlot && (
|
||||
<div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{topSlot}
|
||||
</div>
|
||||
)}
|
||||
<SectionHeader isFirst={!!topSlot}>Library</SectionHeader>
|
||||
<SidebarRow
|
||||
<SidebarNavGroup isFirst={!!topSlot}>Library</SidebarNavGroup>
|
||||
<SidebarNavItem
|
||||
compact
|
||||
active={filter === 'all'}
|
||||
onClick={() => {
|
||||
onFilterChange('all');
|
||||
|
|
@ -226,7 +153,8 @@ export function FinderSidebar({
|
|||
label="All Documents"
|
||||
count={counts.all}
|
||||
/>
|
||||
<SidebarRow
|
||||
<SidebarNavItem
|
||||
compact
|
||||
active={filter === 'recents'}
|
||||
onClick={() => {
|
||||
onFilterChange('recents');
|
||||
|
|
@ -236,8 +164,9 @@ export function FinderSidebar({
|
|||
label="Recently Opened"
|
||||
/>
|
||||
|
||||
<SectionHeader>Kinds</SectionHeader>
|
||||
<SidebarRow
|
||||
<SidebarNavGroup>Kinds</SidebarNavGroup>
|
||||
<SidebarNavItem
|
||||
compact
|
||||
active={filter === 'pdf'}
|
||||
onClick={() => {
|
||||
onFilterChange('pdf');
|
||||
|
|
@ -247,7 +176,8 @@ export function FinderSidebar({
|
|||
label="PDF"
|
||||
count={counts.pdf}
|
||||
/>
|
||||
<SidebarRow
|
||||
<SidebarNavItem
|
||||
compact
|
||||
active={filter === 'epub'}
|
||||
onClick={() => {
|
||||
onFilterChange('epub');
|
||||
|
|
@ -257,7 +187,8 @@ export function FinderSidebar({
|
|||
label="EPUB"
|
||||
count={counts.epub}
|
||||
/>
|
||||
<SidebarRow
|
||||
<SidebarNavItem
|
||||
compact
|
||||
active={filter === 'html'}
|
||||
onClick={() => {
|
||||
onFilterChange('html');
|
||||
|
|
@ -268,11 +199,13 @@ export function FinderSidebar({
|
|||
count={counts.html}
|
||||
/>
|
||||
|
||||
<SectionHeader
|
||||
rightSlot={(
|
||||
<SidebarNavGroup
|
||||
action={(
|
||||
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
|
||||
<MenuButton
|
||||
className="inline-flex items-center justify-center h-3.5 w-5 rounded-sm text-muted hover:text-accent transition-colors duration-200 ease-out focus:outline-none"
|
||||
as={IconButton}
|
||||
size="xs"
|
||||
className="h-3.5 w-5"
|
||||
title="Folder actions"
|
||||
aria-label="Folder actions"
|
||||
>
|
||||
|
|
@ -280,59 +213,47 @@ export function FinderSidebar({
|
|||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enter="transition ease-standard duration-fast"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
<MenuItemsSurface
|
||||
anchor="bottom start"
|
||||
className="z-50 mt-2 min-w-[180px] rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none p-1 normal-case tracking-normal font-normal"
|
||||
className="z-50 mt-2 min-w-[180px] focus:outline-none normal-case tracking-normal font-normal"
|
||||
>
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onNewFolder();
|
||||
onRowAction?.();
|
||||
}}
|
||||
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
>
|
||||
<FolderPlusIcon className="h-4 w-4" />
|
||||
New Folder
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem disabled={folders.length === 0}>
|
||||
{({ active, disabled }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClearFolders();
|
||||
onRowAction?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={`${disabled ? 'text-muted/60 cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Remove All Folders
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
<MenuActionItem
|
||||
onClick={() => {
|
||||
onNewFolder();
|
||||
onRowAction?.();
|
||||
}}
|
||||
>
|
||||
<FolderPlusIcon className="h-4 w-4" />
|
||||
New Folder
|
||||
</MenuActionItem>
|
||||
<MenuActionItem
|
||||
disabled={folders.length === 0}
|
||||
onClick={() => {
|
||||
onClearFolders();
|
||||
onRowAction?.();
|
||||
}}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Remove All Folders
|
||||
</MenuActionItem>
|
||||
</MenuItemsSurface>
|
||||
</Transition>
|
||||
</Menu>
|
||||
)}
|
||||
>
|
||||
Folders
|
||||
</SectionHeader>
|
||||
</SidebarNavGroup>
|
||||
{folders.length === 0 ? (
|
||||
<p className="px-2 py-1 text-[11px] text-muted">No folders yet</p>
|
||||
<p className="px-2 py-1 text-[11px] text-soft">No folders yet</p>
|
||||
) : (
|
||||
folders.map((folder) => (
|
||||
<FolderRow
|
||||
|
|
@ -351,11 +272,11 @@ export function FinderSidebar({
|
|||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SidebarNav>
|
||||
</div>
|
||||
{bottomSlot && (
|
||||
<div
|
||||
className="shrink-0 border-t border-offbase px-2 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] md:pb-2"
|
||||
className="shrink-0 border-t border-line-soft px-2 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] md:pb-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{bottomSlot}
|
||||
|
|
@ -370,8 +291,8 @@ export function FinderSidebar({
|
|||
onPointerMove={onResizeMove}
|
||||
onPointerUp={onResizeEnd}
|
||||
onPointerCancel={onResizeEnd}
|
||||
className="hidden md:block absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-offbase active:bg-accent transition-colors duration-200 ease-out"
|
||||
className="hidden md:block absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-accent-wash active:bg-accent transition-colors duration-base ease-standard"
|
||||
/>
|
||||
</aside>
|
||||
</SidebarShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,14 +19,14 @@ export function FinderStatusBar({
|
|||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="min-h-6 px-3 pb-[env(safe-area-inset-bottom)] flex items-center justify-between gap-3 text-[11px] text-muted bg-base border-t border-offbase select-none"
|
||||
className="min-h-6 px-3 pb-[env(safe-area-inset-bottom)] flex items-center justify-between gap-3 text-[11px] text-soft bg-surface border-t border-line-soft select-none"
|
||||
>
|
||||
<span className="truncate">{summary}</span>
|
||||
<span className="shrink-0">
|
||||
{selectedCount > 0
|
||||
? `${selectedCount} of ${itemCount} selected`
|
||||
: `${itemCount} item${itemCount === 1 ? '' : 's'}`}
|
||||
<span className="mx-1.5 text-muted">•</span>
|
||||
<span className="mx-1.5 text-soft">•</span>
|
||||
{formatDocumentSize(totalSize)}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="sticky top-0 z-40 w-full border-b border-offbase bg-base">
|
||||
<div className="px-2 sm:px-3 py-1 min-h-10 flex items-center gap-1.5 sm:gap-2">
|
||||
<Toolbar>
|
||||
{leftSlot && (
|
||||
<div className="shrink-0 flex items-center gap-2 pr-1 sm:pr-2 sm:border-r sm:border-offbase">
|
||||
<div className="shrink-0 flex items-center gap-2 pr-1 sm:pr-2 sm:border-r sm:border-line">
|
||||
{leftSlot}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
<ToolbarButton
|
||||
onClick={onToggleSidebar}
|
||||
className={`${TOOLBAR_BTN} ${isSidebarOpen ? TOOLBAR_BTN_ACTIVE : TOOLBAR_BTN_INACTIVE} shrink-0`}
|
||||
active={isSidebarOpen}
|
||||
className="shrink-0"
|
||||
aria-pressed={isSidebarOpen}
|
||||
aria-label="Toggle sidebar"
|
||||
title="Toggle sidebar"
|
||||
>
|
||||
<HamburgerIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className={PILL}>
|
||||
<ToolbarGroup>
|
||||
{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'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<ToolbarSegment
|
||||
onClick={() => onViewModeChange(value)}
|
||||
active={active}
|
||||
aria-pressed={active}
|
||||
aria-label={`${label} view`}
|
||||
title={`${label} view`}
|
||||
className={
|
||||
PILL_SEGMENT +
|
||||
' h-6 w-7 ' +
|
||||
(active ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
|
||||
}
|
||||
className="w-7"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</button>
|
||||
</ToolbarSegment>
|
||||
{isIconsToggle && viewMode === 'icons' && (
|
||||
<div
|
||||
className="absolute top-full left-1/2 z-30 -translate-x-1/2 pt-1 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/icons:opacity-100 group-hover/icons:pointer-events-auto group-focus-within/icons:opacity-100 group-focus-within/icons:pointer-events-auto"
|
||||
className="absolute top-full left-1/2 z-30 -translate-x-1/2 pt-1 opacity-0 pointer-events-none transition-opacity duration-fast group-hover/icons:opacity-100 group-hover/icons:pointer-events-auto group-focus-within/icons:opacity-100 group-focus-within/icons:pointer-events-auto"
|
||||
>
|
||||
<div className={`${PILL} shadow-lg`}>
|
||||
<ToolbarGroup className="shadow-elev-2">
|
||||
{ICON_SIZES.map(({ value: sizeValue, label: sizeLabel }) => {
|
||||
const sizeActive = iconSize === sizeValue;
|
||||
return (
|
||||
<button
|
||||
<ToolbarSegment
|
||||
key={sizeValue}
|
||||
type="button"
|
||||
onClick={() => onIconSizeChange(sizeValue)}
|
||||
active={sizeActive}
|
||||
aria-pressed={sizeActive}
|
||||
aria-label={`Icon size ${sizeLabel}`}
|
||||
className={
|
||||
PILL_SEGMENT +
|
||||
' h-6 min-w-[26px] px-1.5 font-semibold tracking-wide ' +
|
||||
(sizeActive ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
|
||||
}
|
||||
className="min-w-[26px] px-1.5 font-semibold tracking-wide"
|
||||
>
|
||||
{sizeLabel}
|
||||
</button>
|
||||
</ToolbarSegment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToolbarGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToolbarGroup>
|
||||
|
||||
{showSortControls && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSortDirectionToggle}
|
||||
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} whitespace-nowrap`}
|
||||
title="Toggle sort direction"
|
||||
>
|
||||
<ToolbarButton onClick={onSortDirectionToggle} className="whitespace-nowrap" title="Toggle sort direction">
|
||||
{directionLabel}
|
||||
</button>
|
||||
</ToolbarButton>
|
||||
<Listbox value={sortBy} onChange={onSortByChange}>
|
||||
<ListboxButton
|
||||
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 min-w-[90px] justify-between`}
|
||||
>
|
||||
<SharedListboxButton tone="toolbar" className="gap-1 min-w-[86px] justify-between">
|
||||
<span>{currentSort.label}</span>
|
||||
<ChevronUpDownIcon className="h-3 w-3 opacity-60" />
|
||||
</ListboxButton>
|
||||
<ListboxOptions
|
||||
anchor="bottom end"
|
||||
className="z-50 mt-1 rounded-md bg-background border border-offbase shadow-lg p-1 focus:outline-none"
|
||||
>
|
||||
</SharedListboxButton>
|
||||
<SharedListboxOptions anchor="bottom end" tone="compact">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<ListboxOption
|
||||
<SharedListboxOption
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className={({ active, selected }) =>
|
||||
`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}
|
||||
</ListboxOption>
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</SharedListboxOptions>
|
||||
</Listbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
<div className="hidden sm:flex items-center gap-1.5 px-2 py-1 rounded-md bg-background border border-offbase hover:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:border-accent transition-colors duration-200 ease-out w-[160px] md:w-[200px]">
|
||||
<SearchIcon className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder="Search"
|
||||
className="flex-1 min-w-0 bg-transparent outline-none text-xs text-foreground placeholder:text-muted"
|
||||
/>
|
||||
</div>
|
||||
<SearchField
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
placeholder="Search"
|
||||
className="hidden w-[160px] md:w-[200px] sm:flex"
|
||||
icon={<SearchIcon className="w-3.5 h-3.5" />}
|
||||
/>
|
||||
|
||||
{rightSlot && (
|
||||
<div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-offbase ml-0.5">
|
||||
<div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-line ml-0.5">
|
||||
{rightSlot}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col h-full w-full bg-background overflow-hidden">
|
||||
{toolbar}
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{!isNarrow && sidebarOpen && (
|
||||
<div className="h-full">{sidebar}</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{statusBar}
|
||||
|
||||
{/* Mobile drawer */}
|
||||
<Transition show={isNarrow && sidebarOpen} as={Fragment}>
|
||||
<Dialog
|
||||
onClose={onRequestSidebarClose ?? (() => undefined)}
|
||||
className="relative z-40 md:hidden"
|
||||
>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="transition-opacity duration-150"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
<div className="fixed inset-y-0 left-0 flex max-w-full">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="transition-transform duration-200 ease-out"
|
||||
enterFrom="-translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition-transform duration-150 ease-in"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="-translate-x-full"
|
||||
>
|
||||
<DialogPanel
|
||||
className="w-[80vw] max-w-[280px] h-full bg-base shadow-xl"
|
||||
>
|
||||
{sidebar}
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</div>
|
||||
<LibraryFrame
|
||||
toolbar={toolbar}
|
||||
sidebar={sidebar}
|
||||
statusBar={statusBar}
|
||||
sidebarOpen={sidebarOpen}
|
||||
onRequestSidebarClose={onRequestSidebarClose}
|
||||
>
|
||||
{children}
|
||||
</LibraryFrame>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-[80]" onClose={() => (closeDisabled ? null : onComplete())}>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel data-testid="migration-modal" className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground mb-4">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted mb-2">
|
||||
Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version.
|
||||
{displayMissingCount > 0 ? (
|
||||
<> {displayMissingCount} {displayMissingCount === 1 ? 'is' : 'are'} not here yet.</>
|
||||
) : null}
|
||||
{' '}This app now stores documents on the server and keeps a local cache for speed.
|
||||
</p>
|
||||
{isUploading && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted">{status}</p>
|
||||
<div className="h-2 w-full rounded bg-offbase">
|
||||
<div className="h-2 rounded bg-accent" style={{ width: `${Math.max(1, Math.round(progress))}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isUploading && status ? <p className="text-xs text-red-500">{status}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button
|
||||
data-testid="migration-skip-button"
|
||||
onClick={handleSkip}
|
||||
disabled={isUploading}
|
||||
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||
font-medium text-foreground hover:bg-offbase focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||
disabled:opacity-50"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background
|
||||
disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? 'Uploading…' : 'Upload'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
<ModalFrame
|
||||
open={isOpen}
|
||||
onClose={() => {
|
||||
if (!closeDisabled) onComplete();
|
||||
}}
|
||||
panelTestId="migration-modal"
|
||||
className="z-[80]"
|
||||
>
|
||||
<ModalTitle className="mb-4">{title}</ModalTitle>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-soft mb-2">
|
||||
Found {localCount} document{localCount === 1 ? '' : 's'} stored locally from an older version.
|
||||
{displayMissingCount > 0 ? (
|
||||
<> {displayMissingCount} {displayMissingCount === 1 ? 'is' : 'are'} not here yet.</>
|
||||
) : null}
|
||||
{' '}This app now stores documents on the server and keeps a local cache for speed.
|
||||
</p>
|
||||
{isUploading && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-soft">{status}</p>
|
||||
<div className="h-2 w-full rounded bg-surface-sunken">
|
||||
<div className="h-2 rounded bg-accent" style={{ width: `${Math.max(1, Math.round(progress))}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)}
|
||||
{!isUploading && status ? <p className="text-xs text-danger">{status}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button
|
||||
data-testid="migration-skip-button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSkip}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? 'Uploading…' : 'Upload'}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFrame>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<button
|
||||
<ToolbarButton
|
||||
onClick={onOpenSegments}
|
||||
className={`inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-in-out hover:scale-[1.09] ${
|
||||
isSegmentsOpen
|
||||
? 'border-accent text-accent bg-offbase'
|
||||
: 'border-offbase text-foreground hover:bg-offbase hover:text-accent'
|
||||
}`}
|
||||
active={isSegmentsOpen}
|
||||
aria-label={isSegmentsOpen ? 'Hide segments sidebar' : 'Open segments sidebar'}
|
||||
title={isSegmentsOpen ? 'Hide Segments' : 'Segments'}
|
||||
>
|
||||
<ListIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09]" />
|
||||
</button>
|
||||
<ListIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
{showAudiobookExport && onOpenAudiobook && (
|
||||
<button
|
||||
<ToolbarButton
|
||||
onClick={onOpenAudiobook}
|
||||
className={`inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-in-out hover:scale-[1.09] ${
|
||||
isAudiobookOpen
|
||||
? 'border-accent text-accent bg-offbase'
|
||||
: 'border-offbase text-foreground hover:bg-offbase hover:text-accent'
|
||||
}`}
|
||||
active={isAudiobookOpen}
|
||||
aria-label={isAudiobookOpen ? 'Hide audiobook export' : 'Open audiobook export'}
|
||||
title={isAudiobookOpen ? 'Hide Export Audiobook' : 'Export Audiobook'}
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09]" />
|
||||
</button>
|
||||
<DownloadIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
|
||||
</ToolbarButton>
|
||||
)}
|
||||
<button
|
||||
<ToolbarButton
|
||||
onClick={onOpenSettings}
|
||||
className={`inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-in-out hover:scale-[1.09] ${
|
||||
isSettingsOpen
|
||||
? 'border-accent text-accent bg-offbase'
|
||||
: 'border-offbase text-foreground hover:bg-offbase hover:text-accent'
|
||||
}`}
|
||||
active={isSettingsOpen}
|
||||
aria-label={isSettingsOpen ? 'Hide settings' : 'Open settings'}
|
||||
title={isSettingsOpen ? 'Hide Settings' : 'Settings'}
|
||||
>
|
||||
<FileSettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09]" />
|
||||
</button>
|
||||
<FileSettingsIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
|
||||
</ToolbarButton>
|
||||
<UserMenu />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -95,35 +84,30 @@ export function DocumentHeaderMenu({
|
|||
<div className="sm:hidden flex items-center">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<MenuButton
|
||||
className="inline-flex items-center justify-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
as={IconButton}
|
||||
tone="surface"
|
||||
size="sm"
|
||||
title="Menu"
|
||||
>
|
||||
<DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
|
||||
<DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-base ease-standard hover:text-accent" />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enter="transition ease-standard duration-fast"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems className="absolute right-0 mt-2 min-w-max origin-top-right divide-y divide-offbase rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none z-50">
|
||||
<MenuItemsSurface className="absolute right-0 z-50 mt-2 min-w-max origin-top-right divide-y divide-line-soft focus:outline-none">
|
||||
{/* Zoom Controls Section */}
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs font-medium text-muted mb-2">Zoom / Padding</p>
|
||||
<p className="text-xs font-medium text-soft mb-2">Zoom / Padding</p>
|
||||
<div className="flex justify-center">
|
||||
<ZoomControl
|
||||
value={zoomLevel}
|
||||
onIncrease={() => {
|
||||
// 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 */}
|
||||
<div className="p-1">
|
||||
{onOpenSegments && (
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={onOpenSegments}
|
||||
className={`${active || isSegmentsOpen ? 'bg-offbase text-accent' : 'text-foreground'
|
||||
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
{isSegmentsOpen ? 'Hide Segments' : 'Segments'}
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuActionItem onClick={onOpenSegments} activeOverride={isSegmentsOpen}>
|
||||
<ListIcon className="h-4 w-4" />
|
||||
{isSegmentsOpen ? 'Hide Segments' : 'Segments'}
|
||||
</MenuActionItem>
|
||||
)}
|
||||
{showAudiobookExport && onOpenAudiobook && (
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={onOpenAudiobook}
|
||||
className={`${active || isAudiobookOpen ? 'bg-offbase text-accent' : 'text-foreground'
|
||||
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
{isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuActionItem onClick={onOpenAudiobook} activeOverride={isAudiobookOpen}>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
{isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
|
||||
</MenuActionItem>
|
||||
)}
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className={`${active || isSettingsOpen ? 'bg-offbase text-accent' : 'text-foreground'
|
||||
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
||||
>
|
||||
<FileSettingsIcon className="h-4 w-4" />
|
||||
{isSettingsOpen ? 'Hide Settings' : 'Settings'}
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuActionItem onClick={onOpenSettings} activeOverride={isSettingsOpen}>
|
||||
<FileSettingsIcon className="h-4 w-4" />
|
||||
{isSettingsOpen ? 'Hide Settings' : 'Settings'}
|
||||
</MenuActionItem>
|
||||
</div>
|
||||
|
||||
{/* Auth Section */}
|
||||
<div className="p-2 border-t border-offbase flex justify-center">
|
||||
<div className="p-2 border-t border-line-soft flex justify-center">
|
||||
<UserMenu />
|
||||
</div>
|
||||
</MenuItems>
|
||||
</MenuItemsSurface>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-[60]" onClose={onClose}>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel className="w-full max-w-2xl transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all flex flex-col h-[80vh]">
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
className="text-lg font-semibold leading-6 text-foreground mb-4 flex-shrink-0 flex justify-between items-center"
|
||||
>
|
||||
{title}
|
||||
{files.length > 0 && (
|
||||
<div className="flex items-center text-sm font-normal">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none text-muted hover:text-foreground transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-muted text-accent focus:ring-accent"
|
||||
checked={allSelected}
|
||||
ref={input => {
|
||||
if (input) input.indeterminate = isIndeterminate;
|
||||
}}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</DialogTitle>
|
||||
|
||||
<div className="flex-1 overflow-auto border border-offbase rounded-lg bg-background p-2 min-h-0">
|
||||
{isLoading ? (
|
||||
<DocumentSelectionSkeleton />
|
||||
) : errorMessage ? (
|
||||
<div className="flex items-center justify-center h-full text-red-500">{errorMessage}</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted">No documents found.</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{files.map((file) => {
|
||||
const isSelected = selectedIds.has(file.id);
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
onClick={(e) => handleRowClick(e, file.id)}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm select-none
|
||||
${isSelected ? 'bg-accent/10' : 'hover:bg-offbase'}
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => handleCheckboxChange(file.id, e.target.checked)}
|
||||
className="rounded border-muted text-accent focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 truncate ${
|
||||
isSelected ? 'text-accent font-medium' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
<div className="text-muted text-xs whitespace-nowrap">{formatSize(file.size)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-3 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass({ variant: 'outline', size: 'md' })}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass({ variant: 'primary', size: 'md' })}
|
||||
onClick={handleConfirmClick}
|
||||
disabled={isLoading || selectedCount === 0 || isProcessing}
|
||||
>
|
||||
{isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
<ModalFrame open={isOpen} onClose={onClose} size="lg" panelClassName="flex h-[80vh] flex-col" className="z-[60]">
|
||||
<ModalTitle className="mb-4 flex flex-shrink-0 items-center justify-between">
|
||||
{title}
|
||||
{files.length > 0 && (
|
||||
<div className="flex items-center text-sm font-normal">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none text-soft hover:text-foreground transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-muted text-accent focus:ring-accent-line"
|
||||
checked={allSelected}
|
||||
ref={input => {
|
||||
if (input) input.indeterminate = isIndeterminate;
|
||||
}}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
)}
|
||||
</ModalTitle>
|
||||
|
||||
<div className="flex-1 overflow-auto border border-line rounded-lg bg-background p-2 min-h-0">
|
||||
{isLoading ? (
|
||||
<DocumentSelectionSkeleton />
|
||||
) : errorMessage ? (
|
||||
<div className="flex items-center justify-center h-full text-danger">{errorMessage}</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-soft">No documents found.</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{files.map((file) => {
|
||||
const isSelected = selectedIds.has(file.id);
|
||||
return (
|
||||
<div
|
||||
key={file.id}
|
||||
onClick={(e) => handleRowClick(e, file.id)}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer text-sm select-none ${
|
||||
isSelected ? 'bg-accent-wash' : 'hover:bg-accent-wash'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => handleCheckboxChange(file.id, e.target.checked)}
|
||||
className="rounded border-muted text-accent focus:ring-accent-line"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 truncate ${
|
||||
isSelected ? 'text-accent font-medium' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
<div className="text-soft text-xs whitespace-nowrap">{formatSize(file.size)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-3 flex-shrink-0">
|
||||
<Button variant="outline" size="md" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={handleConfirmClick}
|
||||
disabled={isLoading || selectedCount === 0 || isProcessing}
|
||||
>
|
||||
{isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFrame>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -237,9 +199,9 @@ function DocumentSelectionSkeleton() {
|
|||
<div className="h-full animate-pulse space-y-0.5" aria-label="Loading documents" aria-busy="true">
|
||||
{rows.map((_, index) => (
|
||||
<div key={index} className="flex items-center gap-3 px-3 py-2 rounded-md">
|
||||
<div className="h-4 w-4 rounded-sm bg-offbase border border-offbase" />
|
||||
<div className="h-3.5 flex-1 rounded bg-offbase" />
|
||||
<div className="h-3 w-14 rounded bg-offbase" />
|
||||
<div className="h-4 w-4 rounded-sm bg-surface-sunken border border-line" />
|
||||
<div className="h-3.5 flex-1 rounded bg-surface-sunken" />
|
||||
<div className="h-3 w-14 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<div className="space-y-1.5">
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">{label}</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
<RangeInput
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className={`flex-1 ${rangeInputClassName}`}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className={`${valueWidth} text-xs font-semibold text-right text-foreground`}>{formatter(value)}</span>
|
||||
</div>
|
||||
|
|
@ -163,27 +159,13 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
|||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">Page mode</label>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Page mode"
|
||||
className={`${segmentedGroupClass} grid-cols-3`}
|
||||
>
|
||||
{viewTypeTextMapping.map((view) => {
|
||||
const active = selectedView.id === view.id;
|
||||
return (
|
||||
<button
|
||||
key={view.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => updateConfigKey('viewType', view.id as ViewType)}
|
||||
className={segmentedButtonClass(active)}
|
||||
>
|
||||
{view.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={selectedView.id as ViewType}
|
||||
options={viewTypeTextMapping.map((view) => ({ value: view.id as ViewType, label: view.name }))}
|
||||
onChange={(nextViewType) => updateConfigKey('viewType', nextViewType)}
|
||||
ariaLabel="Page mode"
|
||||
className="grid-cols-3"
|
||||
/>
|
||||
{selectedView.id === 'scroll' ? (
|
||||
<p className="text-xs text-warning">Scroll mode may be slower on large PDFs.</p>
|
||||
) : null}
|
||||
|
|
@ -214,20 +196,20 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
|||
action={
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="flex items-center gap-1 text-muted">
|
||||
<SparkleIcon className="h-3 w-3 text-accent/70" />
|
||||
<SparkleIcon className="h-3 w-3 text-accent" />
|
||||
<span className="text-xs">PP-DocLayout-V3</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-muted">
|
||||
<span>{pdf.parseStatus ?? 'pending'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass({ variant: 'ghost', size: 'icon', className: '!h-5 !w-5 hover:scale-[1.1] shrink-0' })}
|
||||
<IconButton
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={pdf.onForceReparse}
|
||||
disabled={isForceReparseDisabled(pdf.parseStatus)}
|
||||
title="Force reparse"
|
||||
>
|
||||
<RefreshIcon className={`h-3 w-3 ${isForceReparseDisabled(pdf.parseStatus) ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</IconButton>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -240,7 +222,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
|||
disabled={pdf.parseStatus !== 'ready'}
|
||||
variant="flat"
|
||||
/>
|
||||
<details className="rounded-md border border-offbase bg-base/50 px-3 py-2">
|
||||
<details className="rounded-md border border-offbase bg-surface-solid px-3 py-2">
|
||||
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||
Skip Block Kinds While Reading Aloud
|
||||
</summary>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<input {...getInputProps()} />
|
||||
{children}
|
||||
{isDragActive && (
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background/90 backdrop-blur-md pointer-events-none p-6">
|
||||
<div className="w-full h-full border-2 border-dashed border-accent rounded-xl flex flex-col items-center justify-center bg-base/60 text-center p-4">
|
||||
<UploadIcon className="w-14 h-14 text-accent mb-4 animate-bounce" />
|
||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-surface backdrop-blur-md pointer-events-none p-6">
|
||||
<div className="w-full h-full border-2 border-dashed border-accent rounded-lg flex flex-col items-center justify-center bg-surface-solid text-center p-4">
|
||||
<UploadIcon className="w-14 h-14 text-accent mb-4" />
|
||||
<p className="text-xl font-bold text-foreground mb-1.5">
|
||||
Drop files here to upload
|
||||
</p>
|
||||
<p className="text-sm text-foreground/70">
|
||||
<p className="text-sm text-soft">
|
||||
{enableDocx
|
||||
? 'Accepts PDF, EPUB, TXT, MD, or DOCX'
|
||||
: 'Accepts PDF, EPUB, TXT, or MD'}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="mt-3 text-sm text-red-500">
|
||||
<p className="mt-3 text-sm text-danger">
|
||||
Upload failed: {error} — try again.
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -203,7 +185,7 @@ export function DocumentUploader({
|
|||
</div>
|
||||
)}
|
||||
{!isDragActive && error && (
|
||||
<div className="absolute inset-x-4 bottom-4 z-40 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-center text-sm text-red-500 pointer-events-none">
|
||||
<div className="absolute inset-x-4 bottom-4 z-40 rounded-md border border-danger bg-danger-wash px-3 py-2 text-center text-sm text-danger pointer-events-none">
|
||||
Upload failed: {error} — try again.
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -214,12 +196,17 @@ export function DocumentUploader({
|
|||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`${containerBase} ${borderBgClass} ${paddingClass}`}
|
||||
className={dropzoneSurfaceClass({
|
||||
variant: variant === 'compact' ? 'compact' : 'default',
|
||||
active: isDragActive,
|
||||
disabled: isDisabled,
|
||||
className,
|
||||
})}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{variant === 'compact' ? (
|
||||
<div className="flex items-center gap-2 text-left w-full min-w-0">
|
||||
<UploadIcon className="w-3.5 h-3.5 text-muted group-hover:text-accent shrink-0 transition-colors duration-200" />
|
||||
<UploadIcon className="w-3.5 h-3.5 text-soft group-hover:text-accent shrink-0 transition-colors duration-base" />
|
||||
{isUploading ? (
|
||||
<p className="text-[12px] font-medium truncate flex-1">Uploading…</p>
|
||||
) : isConverting ? (
|
||||
|
|
@ -229,13 +216,13 @@ export function DocumentUploader({
|
|||
<p className="text-[12px] truncate flex-1">
|
||||
{isDragActive ? 'Drop files here' : 'Upload documents'}
|
||||
</p>
|
||||
{error && <p className="text-[10px] text-red-500 truncate shrink-0">{error}</p>}
|
||||
{error && <p className="text-[10px] text-danger truncate shrink-0">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-muted" />
|
||||
<UploadIcon className="w-7 h-7 sm:w-10 sm:h-10 mb-2 text-soft" />
|
||||
{isUploading ? (
|
||||
<p className="text-sm sm:text-lg font-semibold text-foreground">Uploading file...</p>
|
||||
) : isConverting ? (
|
||||
|
|
@ -245,10 +232,10 @@ export function DocumentUploader({
|
|||
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
|
||||
{isDragActive ? 'Drop your file(s) here' : 'Drop your file(s) here, or click to select'}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted">
|
||||
<p className="text-xs sm:text-sm text-soft">
|
||||
{enableDocx ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}
|
||||
</p>
|
||||
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
|
||||
{error && <p className="mt-2 text-sm text-danger">{error}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex items-center gap-1 select-none" aria-label="Zoom controls">
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
onClick={onDecrease}
|
||||
disabled={value <= min}
|
||||
className={buttonClass({
|
||||
variant: 'ghost',
|
||||
size: 'icon',
|
||||
className: 'h-6 w-6 text-sm leading-none hover:scale-[1.09]',
|
||||
})}
|
||||
size="sm"
|
||||
className="h-6 w-6 text-sm leading-none"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="text-xs tabular-nums w-12 text-center text-muted">{value}%</span>
|
||||
<button
|
||||
type="button"
|
||||
</IconButton>
|
||||
<span className="text-xs tabular-nums w-12 text-center text-soft">{value}%</span>
|
||||
<IconButton
|
||||
onClick={onIncrease}
|
||||
disabled={value >= max}
|
||||
className={buttonClass({
|
||||
variant: 'ghost',
|
||||
size: 'icon',
|
||||
className: 'h-6 w-6 text-sm leading-none hover:scale-[1.09]',
|
||||
})}
|
||||
size="sm"
|
||||
className="h-6 w-6 text-sm leading-none"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,301 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useId, type ReactNode } from 'react';
|
||||
import {
|
||||
btnBase,
|
||||
btnDanger,
|
||||
btnGhost,
|
||||
btnOutline,
|
||||
btnPrimary,
|
||||
btnSecondary,
|
||||
buttonClass,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
} from '@/components/ui/buttonPrimitives';
|
||||
|
||||
/**
|
||||
* Shared compact form primitives used by settings-like surfaces across
|
||||
* the app (settings modal, document settings, and admin panels).
|
||||
*/
|
||||
|
||||
export {
|
||||
btnBase,
|
||||
btnDanger,
|
||||
btnGhost,
|
||||
btnOutline,
|
||||
btnPrimary,
|
||||
btnSecondary,
|
||||
buttonClass,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
};
|
||||
|
||||
// Inputs use `bg-base` so they remain visible regardless of whether the
|
||||
// surrounding container is `bg-background` (Card) or `bg-base` (Section).
|
||||
// Using the same `bg-background` as the Card would make the input blend in.
|
||||
// (Note: never use Tailwind alpha modifiers on these theme variables — they
|
||||
// resolve to CSS custom properties that don't accept opacity suffixes.)
|
||||
export const inputClass =
|
||||
'w-full rounded-md bg-background border border-offbase px-2.5 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent';
|
||||
|
||||
export const listboxButtonClass =
|
||||
'relative w-full cursor-pointer rounded-md bg-background border border-offbase py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent hover:bg-base transition-transform duration-200 ease-out hover:scale-[1.01]';
|
||||
export const listboxOptionsClass =
|
||||
'z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background p-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]';
|
||||
export const listboxOptionClass = (active: boolean) =>
|
||||
`relative cursor-pointer select-none rounded-sm py-1.5 pl-9 pr-3 text-sm ${active ? 'bg-offbase text-foreground' : 'text-foreground'}`;
|
||||
|
||||
export const segmentedGroupClass =
|
||||
'grid gap-1 rounded-full border border-offbase bg-background p-1';
|
||||
export const segmentedButtonClass = (active: boolean) =>
|
||||
`rounded-full px-2.5 py-1.5 text-xs font-medium transition-colors transition-transform duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
|
||||
active
|
||||
? 'bg-accent text-background hover:scale-[1.01]'
|
||||
: 'text-muted hover:bg-base hover:text-foreground hover:scale-[1.02]'
|
||||
}`;
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
action,
|
||||
variant = 'panel',
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
action?: ReactNode;
|
||||
variant?: 'panel' | 'flat';
|
||||
}) {
|
||||
if (variant === 'flat') {
|
||||
return (
|
||||
<section className="space-y-2 pb-3 border-b border-offbase last:border-b-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
{subtitle ? <p className="text-xs text-muted mt-0.5">{subtitle}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-offbase bg-base overflow-hidden">
|
||||
<div className="px-3 py-2 bg-background border-b border-offbase">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
{subtitle ? <p className="text-xs text-muted mt-0.5">{subtitle}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-lg border border-offbase bg-background px-3 py-2 transition-transform duration-200 ease-out hover:scale-[1.005] ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type SwitchSize = 'sm' | 'md';
|
||||
|
||||
const SWITCH_SIZE: Record<SwitchSize, { track: string; thumb: string; on: string; off: string }> = {
|
||||
sm: {
|
||||
track: 'h-4 w-7',
|
||||
thumb: 'h-3 w-3',
|
||||
on: 'translate-x-3',
|
||||
off: 'translate-x-0.5',
|
||||
},
|
||||
md: {
|
||||
track: 'h-5 w-9',
|
||||
thumb: 'h-4 w-4',
|
||||
on: 'translate-x-4',
|
||||
off: 'translate-x-0.5',
|
||||
},
|
||||
};
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
ariaDescribedBy,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
size?: SwitchSize;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledBy?: string;
|
||||
ariaDescribedBy?: string;
|
||||
}) {
|
||||
const s = SWITCH_SIZE[size];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex shrink-0 cursor-pointer items-center rounded-full border border-offbase transition-colors duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${s.track} ${
|
||||
checked ? 'bg-accent' : 'bg-offbase'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block rounded-full bg-background shadow-sm ring-0 transition-transform duration-200 ease-out ${s.thumb} ${
|
||||
checked ? s.on : s.off
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToggleRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
right,
|
||||
variant = 'card',
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
right?: ReactNode;
|
||||
variant?: 'card' | 'flat';
|
||||
}) {
|
||||
const labelId = useId();
|
||||
const descId = useId();
|
||||
const rowClass =
|
||||
variant === 'flat'
|
||||
? 'px-0.5 pt-1 pb-2 border-b border-offbase last:border-b-0 transition-transform duration-200 ease-out hover:scale-[1.003]'
|
||||
: 'rounded-md border border-offbase bg-background px-2.5 py-1.5 transition-transform duration-200 ease-out hover:scale-[1.005]';
|
||||
const handleTextToggle = () => {
|
||||
if (!disabled) onChange(!checked);
|
||||
};
|
||||
return (
|
||||
<div className={rowClass}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div
|
||||
className={`flex-1 min-w-0 space-y-0.5 ${disabled ? '' : 'cursor-pointer'}`}
|
||||
onClick={handleTextToggle}
|
||||
>
|
||||
<span id={labelId} className="block text-sm font-medium leading-5 text-foreground">{label}</span>
|
||||
<span id={descId} className="block text-xs leading-4 text-muted">{description}</span>
|
||||
</div>
|
||||
{right ? <div className="shrink-0 self-start pl-1.5">{right}</div> : null}
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
size="md"
|
||||
ariaLabelledBy={labelId}
|
||||
ariaDescribedBy={descId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckItem({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const labelId = useId();
|
||||
const handleTextToggle = () => {
|
||||
if (!disabled) onChange(!checked);
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 py-0.5 group">
|
||||
<span
|
||||
id={labelId}
|
||||
onClick={handleTextToggle}
|
||||
className={`flex-1 min-w-0 truncate text-xs leading-4 text-foreground select-none transition-colors duration-200 ease-out group-hover:text-accent ${
|
||||
disabled ? '' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
ariaLabelledBy={labelId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
className = '',
|
||||
children,
|
||||
}: {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`space-y-1 ${className}`}>
|
||||
{label ? <label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">{label}</label> : null}
|
||||
{children}
|
||||
{hint ? <p className="text-[11px] text-muted">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
tone = 'muted',
|
||||
children,
|
||||
}: {
|
||||
tone?: 'muted' | 'accent' | 'foreground';
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'accent'
|
||||
? 'text-accent'
|
||||
: tone === 'foreground'
|
||||
? 'text-foreground bg-offbase'
|
||||
: 'text-muted bg-offbase';
|
||||
return (
|
||||
<span className={`inline-flex items-center text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 ${toneClass}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -246,7 +246,7 @@ export function GithubIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||
export function PDFIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
className={`w-6 h-6 sm:w-8 sm:h-8 text-red-500 ${props.className || ''}`}
|
||||
className={`w-6 h-6 sm:w-8 sm:h-8 text-danger ${props.className || ''}`}
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
viewBox="-4 0 40 40"
|
||||
|
|
@ -261,7 +261,7 @@ export function PDFIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||
export function EPUBIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
className={`w-6 h-6 sm:w-8 sm:h-8 text-blue-500 ${props.className || ''}`}
|
||||
className={`w-6 h-6 sm:w-8 sm:h-8 text-accent ${props.className || ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
|
@ -283,7 +283,7 @@ export function FileIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||
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}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
|
|
|
|||
30
src/components/layout/AppHeader.tsx
Normal file
30
src/components/layout/AppHeader.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/components/ui/cn';
|
||||
|
||||
export function AppHeader({
|
||||
left,
|
||||
title,
|
||||
right,
|
||||
className,
|
||||
}: {
|
||||
left?: ReactNode;
|
||||
title?: ReactNode;
|
||||
right?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('sticky top-0 z-40 w-full border-b border-line-soft bg-surface', className)} data-app-header>
|
||||
<div className="px-2 sm:px-3 py-1 flex items-center justify-between gap-2 min-h-10">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{left}
|
||||
{typeof title === 'string' ? (
|
||||
<h1 className="text-xs md:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 min-w-0 justify-end">{right}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/components/layout/AppShell.tsx
Normal file
14
src/components/layout/AppShell.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/components/ui/cn';
|
||||
|
||||
export function AppShell({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn('app-shell h-dvh flex flex-col bg-background overflow-hidden', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppMain({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <main className={cn('flex-1 min-h-0 flex flex-col', className)}>{children}</main>;
|
||||
}
|
||||
74
src/components/layout/LibraryFrame.tsx
Normal file
74
src/components/layout/LibraryFrame.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { cn } from '@/components/ui/cn';
|
||||
import { DrawerFrame } from '@/components/ui';
|
||||
|
||||
const NARROW_QUERY = '(max-width: 767px)';
|
||||
|
||||
export function useIsNarrow(): boolean {
|
||||
const [isNarrow, setIsNarrow] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const mq = window.matchMedia(NARROW_QUERY);
|
||||
const update = () => setIsNarrow(mq.matches);
|
||||
update();
|
||||
mq.addEventListener('change', update);
|
||||
return () => mq.removeEventListener('change', update);
|
||||
}, []);
|
||||
return isNarrow;
|
||||
}
|
||||
|
||||
export function LibraryFrame({
|
||||
toolbar,
|
||||
sidebar,
|
||||
statusBar,
|
||||
children,
|
||||
sidebarOpen,
|
||||
onRequestSidebarClose,
|
||||
className,
|
||||
}: {
|
||||
toolbar: ReactNode;
|
||||
sidebar: ReactNode;
|
||||
statusBar: ReactNode;
|
||||
children: ReactNode;
|
||||
sidebarOpen: boolean;
|
||||
onRequestSidebarClose?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const isNarrow = useIsNarrow();
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col overflow-hidden bg-surface-sunken', className)}>
|
||||
{toolbar}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{!isNarrow && sidebarOpen && <div className="h-full">{sidebar}</div>}
|
||||
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">{children}</div>
|
||||
</div>
|
||||
{statusBar}
|
||||
{isNarrow && (
|
||||
<LibrarySidebarDrawer open={sidebarOpen} onClose={onRequestSidebarClose}>
|
||||
{sidebar}
|
||||
</LibrarySidebarDrawer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibrarySidebarDrawer({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="md:hidden">
|
||||
<DrawerFrame open={open} onClose={onClose ?? (() => undefined)} side="left" className="w-[80vw] max-w-[280px]">
|
||||
{children}
|
||||
</DrawerFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/components/layout/index.ts
Normal file
3
src/components/layout/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './AppHeader';
|
||||
export * from './AppShell';
|
||||
export * from './LibraryFrame';
|
||||
|
|
@ -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 (
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* Page back */}
|
||||
<Button
|
||||
<IconButton
|
||||
onClick={() => skipToLocation(currentPage - 1, true)}
|
||||
disabled={currentPage <= 1}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||
className="rounded-full"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</Button>
|
||||
</IconButton>
|
||||
|
||||
{/* Page number popup */}
|
||||
<Popover className="relative mb-1">
|
||||
<PopoverButton
|
||||
className="bg-offbase px-2 py-0.5 rounded-full focus:outline-none cursor-pointer hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
onClick={handlePopoverOpen}
|
||||
>
|
||||
<PopoverTrigger className="rounded-full bg-surface-sunken px-2 py-0.5 text-xs" onClick={handlePopoverOpen}>
|
||||
<p className="text-xs whitespace-nowrap">
|
||||
{currentPage} / {numPages || 1}
|
||||
</p>
|
||||
</PopoverButton>
|
||||
<PopoverPanel anchor="top" className="absolute z-50 bg-base p-3 rounded-md shadow-lg border border-offbase">
|
||||
</PopoverTrigger>
|
||||
<PopoverSurface anchor="top">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="text-xs font-medium text-foreground">Go to page</div>
|
||||
<input
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
className="w-20 px-2 py-1 text-xs text-accent bg-offbase rounded border-none outline-none appearance-none text-center"
|
||||
controlSize="sm"
|
||||
className="w-20 appearance-none border-none text-center text-accent"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputConfirm}
|
||||
|
|
@ -90,22 +89,22 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
|||
placeholder={currentPage.toString()}
|
||||
aria-label="Page number"
|
||||
/>
|
||||
<div className="text-xs text-muted text-center">of {numPages || 1}</div>
|
||||
<div className="text-xs text-soft text-center">of {numPages || 1}</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</PopoverSurface>
|
||||
</Popover>
|
||||
|
||||
{/* Page forward */}
|
||||
<Button
|
||||
<IconButton
|
||||
onClick={() => skipToLocation(currentPage + 1, true)}
|
||||
disabled={currentPage >= (numPages || 1)}
|
||||
className="relative p-2 rounded-full text-foreground hover:bg-offbase data-[hover]:bg-offbase data-[active]:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||
className="rounded-full"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Button>
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Button
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (isPlaying) togglePlay();
|
||||
}}
|
||||
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||
aria-label="Pause"
|
||||
>
|
||||
<PauseIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Popover className="relative">
|
||||
<PopoverButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
|
||||
<PopoverTrigger className="space-x-0.5 px-1.5 py-0.5 text-xs sm:space-x-1 sm:px-2 sm:py-1 sm:text-sm">
|
||||
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
<span className="sm:hidden">{compactTriggerLabel}</span>
|
||||
<span className="hidden sm:inline">{triggerLabel}</span>
|
||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
</PopoverButton>
|
||||
<PopoverPanel anchor="top" className="absolute z-50 bg-base p-3 rounded-md shadow-lg border border-offbase">
|
||||
</PopoverTrigger>
|
||||
<PopoverSurface anchor="top">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{!nativeSpeedSupported && (
|
||||
<div className="rounded-md border border-offbase bg-background px-2 py-1.5 text-[11px] text-muted">
|
||||
<div className="rounded-md border border-line bg-background px-2 py-1.5 text-[11px] text-soft">
|
||||
Native model speed is not available for this model.
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -112,8 +113,7 @@ export const SpeedControl = ({
|
|||
</span>
|
||||
<span className="text-xs">{max.toFixed(1)}x</span>
|
||||
</div>
|
||||
<Input
|
||||
type="range"
|
||||
<RangeInput
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
|
|
@ -122,7 +122,6 @@ export const SpeedControl = ({
|
|||
onMouseUp={handleVoiceSpeedChangeComplete}
|
||||
onKeyUp={handleVoiceSpeedChangeComplete}
|
||||
onTouchEnd={handleVoiceSpeedChangeComplete}
|
||||
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -136,8 +135,7 @@ export const SpeedControl = ({
|
|||
</span>
|
||||
<span className="text-xs">{max.toFixed(1)}x</span>
|
||||
</div>
|
||||
<Input
|
||||
type="range"
|
||||
<RangeInput
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
|
|
@ -146,11 +144,10 @@ export const SpeedControl = ({
|
|||
onMouseUp={handleAudioSpeedChangeComplete}
|
||||
onKeyUp={handleAudioSpeedChangeComplete}
|
||||
onTouchEnd={handleAudioSpeedChangeComplete}
|
||||
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</PopoverSurface>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
|
||||
<div className="sticky bottom-0 z-30 w-full border-t border-line-soft bg-surface" data-app-ttsbar>
|
||||
<div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10">
|
||||
{/* Speed control */}
|
||||
<SpeedControl
|
||||
|
|
@ -49,32 +49,29 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
|||
)}
|
||||
|
||||
{/* Playback Controls */}
|
||||
<Button
|
||||
<IconButton
|
||||
onClick={skipBackward}
|
||||
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||
aria-label="Skip backward"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
</IconButton>
|
||||
|
||||
<Button
|
||||
<IconButton
|
||||
onClick={togglePlay}
|
||||
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||
disabled={isProcessing && !isPlaying}
|
||||
>
|
||||
{isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
</IconButton>
|
||||
|
||||
<Button
|
||||
<IconButton
|
||||
onClick={skipForward}
|
||||
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
|
||||
aria-label="Skip forward"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
</IconButton>
|
||||
|
||||
{/* Voice control */}
|
||||
<VoicesControl availableVoices={availableVoices} setVoiceAndRestart={setVoiceAndRestart} />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="relative">
|
||||
<div className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-muted text-xs sm:text-sm rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
|
||||
<div className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-soft text-xs sm:text-sm rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
|
||||
<AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||
<span>No voices</span>
|
||||
</div>
|
||||
|
|
@ -121,7 +120,7 @@ export function VoicesControlBase({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<ListboxButton className={buttonClass}>
|
||||
<SharedListboxButton tone={buttonTone} className={buttonClass}>
|
||||
{variant === 'field' ? (
|
||||
<>
|
||||
<span className="flex items-center gap-2 truncate text-sm font-medium">
|
||||
|
|
@ -139,15 +138,14 @@ export function VoicesControlBase({
|
|||
<ChevronUpDownIcon className={chevronClass} />
|
||||
</>
|
||||
)}
|
||||
</ListboxButton>
|
||||
<ListboxOptions className={`absolute ${dropdownPosition} z-50 ${dropdownWidth} !h-auto !min-h-0 !max-h-[50vh] overflow-y-auto overscroll-contain rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none`}>
|
||||
</SharedListboxButton>
|
||||
<SharedListboxOptions tone="default" className={cn('absolute !h-auto !min-h-0 !max-h-[50vh]', dropdownPosition, dropdownWidth)}>
|
||||
{availableVoices.map((voiceId) => (
|
||||
<ListboxOption
|
||||
<SharedListboxOption
|
||||
key={voiceId}
|
||||
value={voiceId}
|
||||
className={({ active, selected }) =>
|
||||
`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({
|
|||
<span className="text-xs sm:text-sm">{voiceId}</span>
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</SharedListboxOptions>
|
||||
</Listbox>
|
||||
) : (
|
||||
<Listbox value={currentVoice} onChange={onChangeVoice}>
|
||||
<ListboxButton className={buttonClass}>
|
||||
<SharedListboxButton tone={buttonTone} className={buttonClass}>
|
||||
{variant === 'field' ? (
|
||||
<>
|
||||
<span className="flex items-center gap-2 truncate text-sm font-medium">
|
||||
|
|
@ -183,20 +181,19 @@ export function VoicesControlBase({
|
|||
<ChevronUpDownIcon className={chevronClass} />
|
||||
</>
|
||||
)}
|
||||
</ListboxButton>
|
||||
<ListboxOptions className={`absolute ${dropdownPosition} z-50 ${dropdownWidth} !h-auto !min-h-0 !max-h-[50vh] overflow-y-auto overscroll-contain rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none`}>
|
||||
</SharedListboxButton>
|
||||
<SharedListboxOptions tone="default" className={cn('absolute !h-auto !min-h-0 !max-h-[50vh]', dropdownPosition, dropdownWidth)}>
|
||||
{availableVoices.map((voiceId) => (
|
||||
<ListboxOption
|
||||
<SharedListboxOption
|
||||
key={voiceId}
|
||||
value={voiceId}
|
||||
className={({ active, selected }) =>
|
||||
`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"
|
||||
>
|
||||
<span className="text-xs sm:text-sm">{voiceId}</span>
|
||||
</ListboxOption>
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</ListboxOptions>
|
||||
</SharedListboxOptions>
|
||||
</Listbox>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-opacity ease-out duration-200"
|
||||
enter="transition-opacity ease-standard duration-base"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in duration-150"
|
||||
leave="transition-opacity ease-standard duration-fast"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
|
|
@ -58,38 +58,34 @@ export function ReaderSidebarShell({
|
|||
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-220"
|
||||
enter="transition ease-standard duration-base"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition ease-in duration-180"
|
||||
leave="transition ease-standard duration-base"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<aside
|
||||
role="dialog"
|
||||
aria-label={ariaLabel}
|
||||
className={`reader-sidebar-panel absolute inset-y-0 right-0 sm:right-3 sm:top-3 sm:bottom-3 pointer-events-auto bg-base border-l sm:border border-offbase shadow-xl sm:rounded-xl flex flex-col ${panelClassName}`}
|
||||
className={`reader-sidebar-panel absolute inset-y-0 right-0 sm:right-3 sm:top-3 sm:bottom-3 pointer-events-auto bg-surface border-l sm:border border-line shadow-elev-3 sm:rounded-lg flex flex-col ${panelClassName}`}
|
||||
>
|
||||
<div className="border-b border-offbase px-4 py-3 flex items-start justify-between gap-3">
|
||||
<div className="border-b border-line-soft px-4 py-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
{subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null}
|
||||
{subtitle ? <p className="mt-0.5 text-xs text-soft">{subtitle}</p> : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{headerActions}
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
className={buttonClass({
|
||||
variant: 'secondary',
|
||||
size: 'icon',
|
||||
className: 'h-8 w-8 text-muted',
|
||||
})}
|
||||
tone="surface"
|
||||
className="text-soft"
|
||||
>
|
||||
<XCircleIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
'use client';
|
||||
|
||||
import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Popover, PopoverButton, PopoverPanel, Transition } from '@headlessui/react';
|
||||
import { Popover, PopoverButton, Transition } from '@headlessui/react';
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Book } from 'epubjs';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
|
||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
||||
import { Button, IconButton, PopoverSurface } from '@/components/ui';
|
||||
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
|
||||
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
|
||||
|
|
@ -99,7 +99,7 @@ function settingsAreEqual(a: TTSSegmentSettings | null, b: TTSSegmentSettings |
|
|||
|
||||
function statusColor(status: TTSSegmentVariant['status']): string {
|
||||
if (status === 'completed') return 'bg-accent';
|
||||
if (status === 'error') return 'bg-red-500';
|
||||
if (status === 'error') return 'bg-danger';
|
||||
return 'bg-muted';
|
||||
}
|
||||
|
||||
|
|
@ -632,33 +632,27 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
|||
|
||||
const headerActions = (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => void handleClearCache()}
|
||||
aria-label="Clear segments cache"
|
||||
title="Clear cache for listed segments"
|
||||
disabled={isClearingSegments}
|
||||
className={buttonClass({
|
||||
variant: 'secondary',
|
||||
size: 'xs',
|
||||
className: 'h-8 px-2 text-muted',
|
||||
})}
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className="h-8 px-2 text-soft"
|
||||
>
|
||||
{isClearingSegments ? 'Clearing…' : 'Clear'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<IconButton
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh segments"
|
||||
title="Refresh"
|
||||
className={buttonClass({
|
||||
variant: 'secondary',
|
||||
size: 'icon',
|
||||
className: 'h-8 w-8 text-muted',
|
||||
})}
|
||||
tone="surface"
|
||||
size="md"
|
||||
className="h-8 w-8 text-soft"
|
||||
>
|
||||
<RefreshIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
@ -672,8 +666,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
|||
headerActions={headerActions}
|
||||
bodyClassName="flex-1 overflow-y-auto px-0 py-0"
|
||||
>
|
||||
<div className="px-4 py-2 border-b border-offbase">
|
||||
<div className="text-xs text-muted">
|
||||
<div className="px-4 py-2 border-b border-line-soft">
|
||||
<div className="text-xs text-soft">
|
||||
{hasLoadedManifest ? (
|
||||
<>
|
||||
{rowsToRender.length} indexed
|
||||
|
|
@ -687,9 +681,9 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
|||
) : null}
|
||||
</>
|
||||
) : isManifestLoading ? (
|
||||
<div className="animate-pulse h-3 w-36 rounded bg-offbase" aria-label="Loading segment summary" aria-busy="true" />
|
||||
<div className="animate-pulse h-3 w-36 rounded bg-surface-sunken" aria-label="Loading segment summary" aria-busy="true" />
|
||||
) : hasManifestError ? (
|
||||
<span className="text-red-500">error</span>
|
||||
<span className="text-danger">error</span>
|
||||
) : (
|
||||
<span>—</span>
|
||||
)}
|
||||
|
|
@ -698,23 +692,23 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
|||
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto">
|
||||
{hasManifestError && (
|
||||
<div className="px-4 py-6 text-sm text-red-500">{manifestErrorMessage}</div>
|
||||
<div className="px-4 py-6 text-sm text-danger">{manifestErrorMessage}</div>
|
||||
)}
|
||||
{isManifestLoading && (
|
||||
<SegmentsListSkeleton />
|
||||
)}
|
||||
{hasLoadedManifest && rowsToRender.length === 0 && (
|
||||
<div className="px-4 py-10 flex flex-col items-center text-center gap-2">
|
||||
<div className="text-sm font-medium text-muted">
|
||||
<div className="text-sm font-medium text-soft">
|
||||
No segments
|
||||
</div>
|
||||
<p className="text-sm text-muted leading-relaxed max-w-[24ch]">
|
||||
<p className="text-sm text-soft leading-relaxed max-w-[24ch]">
|
||||
Press play in the reader to generate audio segments.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{hasLoadedManifest && rowsToRender.length > 0 && (
|
||||
<ul className="divide-y divide-offbase">
|
||||
<ul className="divide-y divide-line-soft">
|
||||
{rowsToRender.map(({ segmentIndex, sentenceText, row, isCurrentLocation, groupKey, groupLabel, isSynthesized }, rowIndex) => {
|
||||
const previousGroupKey = rowIndex > 0 ? rowsToRender[rowIndex - 1]?.groupKey : null;
|
||||
const showGroupHeader = previousGroupKey !== groupKey;
|
||||
|
|
@ -741,11 +735,11 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
|||
<li
|
||||
key={`${isSynthesized ? 'syn' : 'mfr'}::${groupKey}::${segmentIndex}::${rowIndex}`}
|
||||
data-active-segment={isCurrent ? 'true' : undefined}
|
||||
className={`relative px-4 py-3 ${isCurrent ? 'bg-offbase/40' : ''}`}
|
||||
className={`relative px-4 py-3 ${isCurrent ? 'bg-surface-sunken' : ''}`}
|
||||
>
|
||||
{showGroupHeader && (
|
||||
<div className="mb-2 -mx-4 px-4 py-1.5 bg-offbase/30 border-y border-offbase">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-muted">
|
||||
<div className="mb-2 -mx-4 px-4 py-1.5 bg-surface-sunken border-y border-line">
|
||||
<span className="text-[10px] uppercase tracking-[0.14em] text-soft">
|
||||
{groupLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -758,7 +752,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
|||
type="button"
|
||||
onClick={() => { if (canJump) handleJump(segmentIndex, row.locator); }}
|
||||
disabled={!canJump}
|
||||
className={`text-xs font-medium shrink-0 pt-0.5 ${canJump ? 'text-muted hover:text-accent' : 'text-muted/50 cursor-not-allowed'}`}
|
||||
className={`text-xs font-medium shrink-0 pt-0.5 ${canJump ? 'text-soft hover:text-accent' : 'text-faint cursor-not-allowed'}`}
|
||||
title={canJump ? (playable ? 'Play this segment' : 'Jump to this segment') : 'Text not loaded yet'}
|
||||
aria-label={`Segment ${segmentIndex + 1}`}
|
||||
>
|
||||
|
|
@ -772,9 +766,9 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
|||
disabled={!canJump}
|
||||
className={`block w-full text-left ${canJump ? '' : 'cursor-not-allowed'}`}
|
||||
>
|
||||
<p className={`text-sm leading-snug ${isCurrent ? 'text-foreground' : 'text-foreground/90'} line-clamp-2`}>
|
||||
<p className={`text-sm leading-snug ${isCurrent ? 'text-foreground' : 'text-soft'} line-clamp-2`}>
|
||||
{sentenceText || (
|
||||
<span className="text-muted italic text-xs">
|
||||
<span className="text-soft italic text-xs">
|
||||
[text not loaded — press play to fetch]
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -787,7 +781,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
|||
aria-label={`Status ${status}`}
|
||||
title={status}
|
||||
/>
|
||||
<span className="text-xs text-muted">
|
||||
<span className="text-xs text-soft">
|
||||
{formatDuration(activeVariant?.durationMs)}
|
||||
</span>
|
||||
{isCurrent && isPlaying && (
|
||||
|
|
@ -796,7 +790,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
|||
</span>
|
||||
)}
|
||||
{!canJump && (
|
||||
<span className="text-[10px] text-muted/80 border border-offbase rounded px-1 py-0.5">
|
||||
<span className="text-[10px] text-faint border border-line rounded px-1 py-0.5">
|
||||
not loaded
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -820,10 +814,10 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
|||
className={[
|
||||
'max-w-full whitespace-normal break-words text-left leading-none text-[10px] px-1 py-0.5 rounded border transition-colors',
|
||||
isActive
|
||||
? 'border-accent text-accent bg-offbase/60'
|
||||
? 'border-accent text-accent bg-surface-sunken'
|
||||
: known
|
||||
? 'border-offbase text-muted hover:border-accent hover:text-accent'
|
||||
: 'border-offbase text-muted opacity-60 cursor-not-allowed',
|
||||
? 'border-line text-soft hover:border-accent hover:text-accent'
|
||||
: 'border-line text-soft opacity-60 cursor-not-allowed',
|
||||
].join(' ')}
|
||||
>
|
||||
{formatVoiceLabel(variant.settings)}
|
||||
|
|
@ -854,21 +848,21 @@ function SegmentsListSkeleton() {
|
|||
return (
|
||||
<div className="px-4 py-3">
|
||||
<div className="animate-pulse space-y-3" aria-label="Loading segments" aria-busy="true">
|
||||
<div className="h-3 w-40 rounded bg-offbase" />
|
||||
<div className="h-3 w-40 rounded bg-surface-sunken" />
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className="rounded-md border border-offbase bg-base px-3 py-2.5">
|
||||
<div key={index} className="rounded-md border border-line bg-surface px-3 py-2.5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-3.5 w-8 rounded bg-offbase mt-0.5 shrink-0" />
|
||||
<div className="h-3.5 w-8 rounded bg-surface-sunken mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="h-3.5 w-11/12 rounded bg-offbase" />
|
||||
<div className="h-3.5 w-3/4 rounded bg-offbase" />
|
||||
<div className="h-3.5 w-11/12 rounded bg-surface-sunken" />
|
||||
<div className="h-3.5 w-3/4 rounded bg-surface-sunken" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-offbase" />
|
||||
<div className="h-3 w-12 rounded bg-offbase" />
|
||||
<div className="h-4 w-20 rounded bg-offbase" />
|
||||
<div className="h-2 w-2 rounded-full bg-surface-sunken" />
|
||||
<div className="h-3 w-12 rounded bg-surface-sunken" />
|
||||
<div className="h-4 w-20 rounded bg-surface-sunken" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6 w-6 rounded bg-offbase shrink-0" />
|
||||
<div className="h-6 w-6 rounded bg-surface-sunken shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -882,7 +876,7 @@ function SegmentsListSkeletonRows() {
|
|||
<div className="px-4 py-3 animate-pulse" aria-label="Loading more segments" aria-busy="true">
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-12 rounded-md border border-offbase bg-base" />
|
||||
<div key={index} className="h-12 rounded-md border border-line bg-surface" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -893,24 +887,25 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
|||
return (
|
||||
<Popover className="relative shrink-0">
|
||||
<PopoverButton
|
||||
as={IconButton}
|
||||
size="sm"
|
||||
aria-label="Segment metadata"
|
||||
title="Metadata"
|
||||
className="inline-flex items-center justify-center w-7 h-7 rounded-md border border-transparent text-muted hover:bg-offbase hover:border-offbase hover:text-accent transition-colors"
|
||||
>
|
||||
<InfoIcon className="w-3.5 h-3.5" />
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enter="transition ease-standard duration-fast"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-100"
|
||||
leave="transition ease-standard duration-fast"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel
|
||||
<PopoverSurface
|
||||
anchor="bottom end"
|
||||
className="z-[60] w-[300px] mt-1 rounded-lg border border-offbase bg-base shadow-xl p-3"
|
||||
className="z-[60] w-[300px] mt-1"
|
||||
>
|
||||
<dl className="space-y-2">
|
||||
<Row label="locator">
|
||||
|
|
@ -925,7 +920,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
|||
: `${row.locator.readerType || '?'} (legacy)`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted text-[11px]">none</span>
|
||||
<span className="text-soft text-[11px]">none</span>
|
||||
)}
|
||||
</Row>
|
||||
<Row label="variants">
|
||||
|
|
@ -934,9 +929,9 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
|||
</span>
|
||||
</Row>
|
||||
{row.variants.map((v) => (
|
||||
<div key={v.segmentId} className="border-t border-offbase pt-2">
|
||||
<div key={v.segmentId} className="border-t border-line-soft pt-2">
|
||||
<Row label="segment_id">
|
||||
<span className="font-mono text-[10px] text-muted break-all">
|
||||
<span className="font-mono text-[10px] text-soft break-all">
|
||||
{v.segmentId.slice(0, 16)}…
|
||||
</span>
|
||||
</Row>
|
||||
|
|
@ -963,7 +958,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
|||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</PopoverPanel>
|
||||
</PopoverSurface>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
|
|
@ -972,7 +967,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
|||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[80px_1fr] gap-2 items-baseline">
|
||||
<dt className="font-mono uppercase tracking-[0.16em] text-[9px] text-muted">{label}</dt>
|
||||
<dt className="font-mono uppercase tracking-[0.16em] text-[9px] text-soft">{label}</dt>
|
||||
<dd className="min-w-0">{children}</dd>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
35
src/components/ui/badge.tsx
Normal file
35
src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { variants } from './variants';
|
||||
|
||||
export type BadgeTone = 'muted' | 'accent' | 'foreground' | 'danger';
|
||||
|
||||
export const badgeStyles = variants({
|
||||
base: 'inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
||||
variants: {
|
||||
tone: {
|
||||
muted: 'bg-surface-sunken text-soft',
|
||||
accent: 'bg-accent-wash text-accent',
|
||||
foreground: 'bg-surface-sunken text-foreground',
|
||||
danger: 'bg-danger-wash text-danger',
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
tone: 'muted',
|
||||
},
|
||||
});
|
||||
|
||||
export function Badge({
|
||||
tone = 'muted',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement> & {
|
||||
tone?: BadgeTone;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className={badgeStyles({ tone, className })} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
114
src/components/ui/button.tsx
Normal file
114
src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import Link, { type LinkProps } from 'next/link';
|
||||
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from './cn';
|
||||
import { variants } from './variants';
|
||||
import { focusRing, motionColors } from './tokens';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
|
||||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
|
||||
|
||||
export const buttonStyles = variants({
|
||||
base: cn(
|
||||
'inline-flex items-center justify-center font-medium disabled:cursor-not-allowed disabled:opacity-50',
|
||||
focusRing,
|
||||
motionColors,
|
||||
),
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-accent text-background hover:bg-secondary-accent',
|
||||
secondary: 'border border-line bg-surface text-foreground hover:bg-accent-wash',
|
||||
outline: 'border border-line bg-surface-sunken text-foreground hover:bg-accent-wash hover:text-accent',
|
||||
danger: 'border border-danger bg-danger text-background hover:bg-danger-strong hover:border-danger-strong',
|
||||
ghost: 'bg-transparent text-foreground hover:bg-accent-wash hover:text-accent',
|
||||
},
|
||||
size: {
|
||||
xs: 'h-6 rounded-md px-2 text-xs',
|
||||
sm: 'h-7 rounded-md px-2.5 text-xs',
|
||||
md: 'h-8 rounded-md px-3 text-sm',
|
||||
lg: 'h-10 rounded-lg px-4 text-base',
|
||||
icon: 'h-8 w-8 rounded-md p-0 text-sm',
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
variant: 'secondary',
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
export const btnBase = cn(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50',
|
||||
focusRing,
|
||||
motionColors,
|
||||
);
|
||||
export const btnPrimary = buttonStyles({ variant: 'primary', size: 'md' });
|
||||
export const btnSecondary = buttonStyles({ variant: 'secondary', size: 'md' });
|
||||
export const btnOutline = buttonStyles({ variant: 'outline', size: 'md' });
|
||||
export const btnDanger = buttonStyles({ variant: 'danger', size: 'md' });
|
||||
export const btnGhost = buttonStyles({ variant: 'ghost', size: 'md' });
|
||||
|
||||
function buttonClass({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
className = '',
|
||||
}: {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
} = {}) {
|
||||
return buttonStyles({ variant, size, className });
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
className,
|
||||
children,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button type={type} className={buttonClass({ variant, size, className })} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ButtonLink({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: LinkProps & AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link className={buttonClass({ variant, size, className })} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function ButtonAnchor({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a className={buttonClass({ variant, size, className })} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
export const btnBase =
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transition-colors transition-transform duration-200 ease-out disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100';
|
||||
export const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.03]`;
|
||||
export const btnSecondary = `${btnBase} bg-base text-foreground border border-offbase hover:bg-offbase hover:scale-[1.03]`;
|
||||
export const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-base hover:text-accent hover:scale-[1.02]`;
|
||||
export const btnDanger = `${btnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02]`;
|
||||
export const btnGhost = `${btnBase} bg-transparent text-foreground hover:bg-base hover:text-accent`;
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
|
||||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
|
||||
|
||||
const BUTTON_VARIANT_CLASS: Record<ButtonVariant, string> = {
|
||||
primary: btnPrimary,
|
||||
secondary: btnSecondary,
|
||||
outline: btnOutline,
|
||||
danger: btnDanger,
|
||||
ghost: btnGhost,
|
||||
};
|
||||
|
||||
const BUTTON_SIZE_CLASS: Record<ButtonSize, string> = {
|
||||
xs: 'h-6 px-2 text-xs rounded-md',
|
||||
sm: 'h-7 px-2.5 text-xs rounded-md',
|
||||
md: 'h-8 px-3 text-sm rounded-md',
|
||||
lg: 'h-10 px-4 text-base rounded-lg',
|
||||
icon: 'h-8 w-8 rounded-md p-0',
|
||||
};
|
||||
|
||||
export function buttonClass({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
className = '',
|
||||
}: {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
} = {}) {
|
||||
return [BUTTON_VARIANT_CLASS[variant], BUTTON_SIZE_CLASS[size], className].filter(Boolean).join(' ');
|
||||
}
|
||||
32
src/components/ui/choice-tile.tsx
Normal file
32
src/components/ui/choice-tile.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from './cn';
|
||||
import { focusRing, motionColors } from './tokens';
|
||||
|
||||
export function ChoiceTile({
|
||||
selected = false,
|
||||
children,
|
||||
className,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
selected?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border px-2 py-1.5 text-left',
|
||||
'transition duration-base ease-standard',
|
||||
focusRing,
|
||||
motionColors,
|
||||
selected ? 'border-accent-line' : 'border-line hover:border-accent-line',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
5
src/components/ui/cn.ts
Normal file
5
src/components/ui/cn.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type ClassValue = string | false | null | undefined;
|
||||
|
||||
export function cn(...classes: ClassValue[]) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
158
src/components/ui/dialog.tsx
Normal file
158
src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
'use client';
|
||||
|
||||
import { Fragment, type KeyboardEventHandler, type ReactNode } from 'react';
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||
import { variants } from './variants';
|
||||
import { cn } from './cn';
|
||||
|
||||
export type DialogSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
export const dialogPanelStyles = variants({
|
||||
base: 'w-full transform rounded-lg border border-line bg-surface text-left align-middle shadow-elev-3 transition',
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'max-w-md p-5',
|
||||
md: 'max-w-md p-6',
|
||||
lg: 'max-w-2xl p-6',
|
||||
xl: 'max-w-4xl overflow-hidden',
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
export function DialogShell({
|
||||
children,
|
||||
className,
|
||||
size = 'md',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
size?: DialogSize;
|
||||
}) {
|
||||
return <div className={dialogPanelStyles({ size, className })}>{children}</div>;
|
||||
}
|
||||
|
||||
export function ModalFrame({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
size = 'md',
|
||||
className,
|
||||
panelClassName,
|
||||
panelTestId,
|
||||
onKeyDown,
|
||||
afterLeave,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
size?: DialogSize;
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
panelTestId?: string;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
|
||||
afterLeave?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Transition appear show={open} as={Fragment} afterLeave={afterLeave}>
|
||||
<Dialog as="div" role={undefined} className={cn('relative z-50', className)} onClose={onClose} onKeyDown={onKeyDown}>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-standard duration-slow"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-standard duration-base"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-standard duration-slow"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-standard duration-base"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<DialogPanel data-testid={panelTestId} className={dialogPanelStyles({ size, className: panelClassName })}>
|
||||
{children}
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModalTitle({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<DialogTitle as="h3" className={cn('text-lg font-semibold leading-6 text-foreground', className)}>
|
||||
{children}
|
||||
</DialogTitle>
|
||||
);
|
||||
}
|
||||
|
||||
export function DrawerFrame({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
side = 'left',
|
||||
className,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
side?: 'left' | 'right';
|
||||
className?: string;
|
||||
}) {
|
||||
const sideClass = side === 'left' ? 'left-0' : 'right-0';
|
||||
const enterFrom = side === 'left' ? '-translate-x-full' : 'translate-x-full';
|
||||
const leaveTo = enterFrom;
|
||||
|
||||
return (
|
||||
<Transition show={open} as={Fragment}>
|
||||
<Dialog onClose={onClose} className="relative z-40">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="transition-opacity duration-fast"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity duration-fast"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
|
||||
</TransitionChild>
|
||||
<div className={cn('fixed inset-y-0 flex max-w-full', sideClass)}>
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="transition-transform duration-base ease-standard"
|
||||
enterFrom={enterFrom}
|
||||
enterTo="translate-x-0"
|
||||
leave="transition-transform duration-fast ease-standard"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo={leaveTo}
|
||||
>
|
||||
<DialogPanel className={cn('h-full bg-surface shadow-elev-3', className)}>
|
||||
{children}
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
6
src/components/ui/divider.tsx
Normal file
6
src/components/ui/divider.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from './cn';
|
||||
|
||||
export function Divider({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div aria-hidden="true" className={cn('border-t border-line-soft', className)} {...props} />;
|
||||
}
|
||||
40
src/components/ui/dropzone.ts
Normal file
40
src/components/ui/dropzone.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { cn } from './cn';
|
||||
import { variants } from './variants';
|
||||
|
||||
export type DropzoneVariant = 'default' | 'compact';
|
||||
|
||||
export const dropzoneStyles = variants({
|
||||
base: 'group w-full cursor-pointer border-dashed text-foreground transition-colors duration-base ease-standard disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'rounded-lg border-2 px-3 py-5',
|
||||
compact: 'rounded-md border px-2 py-1',
|
||||
},
|
||||
active: {
|
||||
true: 'border-accent bg-surface text-accent',
|
||||
false: 'border-line bg-transparent hover:border-accent hover:bg-accent-wash hover:text-accent',
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
variant: 'default',
|
||||
active: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
export function dropzoneSurfaceClass({
|
||||
variant = 'default',
|
||||
active = false,
|
||||
disabled = false,
|
||||
className,
|
||||
}: {
|
||||
variant?: DropzoneVariant;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return dropzoneStyles({
|
||||
variant,
|
||||
active: active ? 'true' : 'false',
|
||||
className: cn(disabled && 'pointer-events-none cursor-not-allowed opacity-50', className),
|
||||
});
|
||||
}
|
||||
107
src/components/ui/field.tsx
Normal file
107
src/components/ui/field.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
'use client';
|
||||
|
||||
import { useId, type ReactNode } from 'react';
|
||||
import { cn } from './cn';
|
||||
import { Switch } from './switch';
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('space-y-1', className)}>
|
||||
{label ? <label className="block text-[11px] font-semibold uppercase tracking-wide text-faint">{label}</label> : null}
|
||||
{children}
|
||||
{hint ? <p className="text-[11px] text-faint">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToggleRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
right,
|
||||
variant = 'card',
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
right?: ReactNode;
|
||||
variant?: 'card' | 'flat';
|
||||
}) {
|
||||
const labelId = useId();
|
||||
const descId = useId();
|
||||
const rowClass =
|
||||
variant === 'flat'
|
||||
? 'px-0.5 pt-1 pb-2 border-b border-line-soft last:border-b-0 transition-colors duration-fast ease-standard'
|
||||
: 'rounded-md border border-line bg-surface px-2.5 py-1.5 transition-colors duration-fast ease-standard';
|
||||
const handleTextToggle = () => {
|
||||
if (!disabled) onChange(!checked);
|
||||
};
|
||||
return (
|
||||
<div className={rowClass}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div
|
||||
className={cn('flex-1 min-w-0 space-y-0.5', disabled ? '' : 'cursor-pointer')}
|
||||
onClick={handleTextToggle}
|
||||
>
|
||||
<span id={labelId} className="block text-sm font-medium leading-5 text-foreground">{label}</span>
|
||||
<span id={descId} className="block text-xs leading-4 text-soft">{description}</span>
|
||||
</div>
|
||||
{right ? <div className="shrink-0 self-start pl-1.5">{right}</div> : null}
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
size="md"
|
||||
ariaLabelledBy={labelId}
|
||||
ariaDescribedBy={descId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckItem({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const labelId = useId();
|
||||
const handleTextToggle = () => {
|
||||
if (!disabled) onChange(!checked);
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 py-0.5 group">
|
||||
<span
|
||||
id={labelId}
|
||||
onClick={handleTextToggle}
|
||||
className={cn(
|
||||
'flex-1 min-w-0 truncate text-xs leading-4 text-foreground select-none transition-colors duration-fast ease-standard group-hover:text-accent',
|
||||
disabled ? '' : 'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<Switch checked={checked} onChange={onChange} disabled={disabled} size="sm" ariaLabelledBy={labelId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/components/ui/icon-button.tsx
Normal file
47
src/components/ui/icon-button.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
import { cn } from './cn';
|
||||
import { variants } from './variants';
|
||||
import { focusRing, motionColors } from './tokens';
|
||||
|
||||
export type IconButtonTone = 'ghost' | 'surface' | 'danger';
|
||||
export type IconButtonSize = 'xs' | 'sm' | 'md' | 'lg';
|
||||
|
||||
export const iconButtonStyles = variants({
|
||||
base: cn('inline-flex items-center justify-center disabled:cursor-not-allowed disabled:opacity-50', focusRing, motionColors),
|
||||
variants: {
|
||||
tone: {
|
||||
ghost: 'text-soft hover:bg-accent-wash hover:text-accent',
|
||||
surface: 'border border-line bg-surface text-foreground hover:bg-accent-wash hover:text-accent',
|
||||
danger: 'text-danger hover:bg-danger-wash',
|
||||
},
|
||||
size: {
|
||||
xs: 'h-5 w-5 rounded-sm text-xs',
|
||||
sm: 'h-7 w-7 rounded-md text-xs',
|
||||
md: 'h-8 w-8 rounded-md text-sm',
|
||||
lg: 'h-10 w-10 rounded-lg text-base',
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
tone: 'ghost',
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
export const IconButton = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
children: ReactNode;
|
||||
tone?: IconButtonTone;
|
||||
size?: IconButtonSize;
|
||||
}>(function IconButton({
|
||||
className,
|
||||
children,
|
||||
tone = 'ghost',
|
||||
size = 'md',
|
||||
type = 'button',
|
||||
...props
|
||||
}, ref) {
|
||||
return (
|
||||
<button ref={ref} type={type} className={iconButtonStyles({ tone, size, className })} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
24
src/components/ui/index.ts
Normal file
24
src/components/ui/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export * from './badge';
|
||||
export * from './button';
|
||||
export * from './choice-tile';
|
||||
export * from './cn';
|
||||
export * from './dialog';
|
||||
export * from './divider';
|
||||
export * from './dropzone';
|
||||
export * from './field';
|
||||
export * from './icon-button';
|
||||
export * from './input';
|
||||
export * from './menu';
|
||||
export * from './popover';
|
||||
export * from './range';
|
||||
export * from './search-field';
|
||||
export * from './section';
|
||||
export * from './select';
|
||||
export * from './segmented-control';
|
||||
export * from './sidebar';
|
||||
export * from './sidebar-nav';
|
||||
export * from './surface';
|
||||
export * from './switch';
|
||||
export * from './tokens';
|
||||
export * from './toolbar';
|
||||
export * from './variants';
|
||||
38
src/components/ui/input.tsx
Normal file
38
src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
|
||||
import { cn } from './cn';
|
||||
import { variants } from './variants';
|
||||
import { motionColors } from './tokens';
|
||||
|
||||
export type InputControlSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export const inputStyles = variants({
|
||||
base: cn('w-full border border-line bg-surface-sunken text-foreground placeholder:text-soft focus:border-accent-line focus:outline-none focus:ring-2 focus:ring-accent-line', motionColors),
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'rounded-md px-2 py-1 text-xs',
|
||||
md: 'rounded-md px-2.5 py-1.5 text-sm',
|
||||
lg: 'rounded-lg px-3 py-2 text-sm',
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
export const inputClass = inputStyles();
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }>(function Input({
|
||||
className,
|
||||
controlSize = 'md',
|
||||
...props
|
||||
}, ref) {
|
||||
return <input ref={ref} className={inputStyles({ size: controlSize, className })} {...props} />;
|
||||
});
|
||||
|
||||
export function Textarea({
|
||||
className,
|
||||
controlSize = 'md',
|
||||
...props
|
||||
}: TextareaHTMLAttributes<HTMLTextAreaElement> & { controlSize?: InputControlSize }) {
|
||||
return <textarea className={inputStyles({ size: controlSize, className })} {...props} />;
|
||||
}
|
||||
66
src/components/ui/menu.tsx
Normal file
66
src/components/ui/menu.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { MenuItem, MenuItems } from '@headlessui/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from './cn';
|
||||
|
||||
const menuPanelClass = 'rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft';
|
||||
|
||||
export function Menu({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
|
||||
return (
|
||||
<div className={cn(menuPanelClass, className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function menuItemClass(active: boolean, tone: 'default' | 'danger' = 'default') {
|
||||
if (tone === 'danger') {
|
||||
return cn('flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs text-danger', active && 'bg-danger-wash');
|
||||
}
|
||||
return cn('flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs', active ? 'bg-accent-wash text-accent' : 'text-foreground');
|
||||
}
|
||||
|
||||
export function MenuItemsSurface({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof MenuItems>) {
|
||||
return (
|
||||
<MenuItems className={cn(menuPanelClass, className)} {...props}>
|
||||
{children}
|
||||
</MenuItems>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuActionItem({
|
||||
tone = 'default',
|
||||
activeOverride = false,
|
||||
disabled = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: Omit<ComponentProps<'button'>, 'className' | 'children'> & {
|
||||
tone?: 'default' | 'danger';
|
||||
activeOverride?: boolean;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<MenuItem disabled={disabled}>
|
||||
{({ active, disabled: itemDisabled }) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={itemDisabled}
|
||||
className={cn(menuItemClass(active || activeOverride, tone), itemDisabled && 'cursor-not-allowed text-faint', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
35
src/components/ui/popover.tsx
Normal file
35
src/components/ui/popover.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { cn } from './cn';
|
||||
|
||||
const popoverPanelClass = cn(
|
||||
'z-50 rounded-md border border-line bg-surface p-3 shadow-elev-2 focus:outline-none',
|
||||
);
|
||||
|
||||
export const popoverTriggerClass = cn(
|
||||
'inline-flex items-center rounded-md text-foreground hover:bg-accent-wash hover:text-accent focus:outline-none transition-colors duration-fast ease-standard',
|
||||
);
|
||||
|
||||
export function PopoverTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof PopoverButton>) {
|
||||
return (
|
||||
<PopoverButton className={cn(popoverTriggerClass, className)} {...props}>
|
||||
{children}
|
||||
</PopoverButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function PopoverSurface({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof PopoverPanel>) {
|
||||
return (
|
||||
<PopoverPanel className={cn(popoverPanelClass, className)} {...props}>
|
||||
{children}
|
||||
</PopoverPanel>
|
||||
);
|
||||
}
|
||||
64
src/components/ui/range.tsx
Normal file
64
src/components/ui/range.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { CSSProperties, InputHTMLAttributes } from 'react';
|
||||
import { cn } from './cn';
|
||||
|
||||
type RangeStyle = CSSProperties & {
|
||||
'--range-progress'?: string;
|
||||
};
|
||||
|
||||
function toNumber(value: string | number | readonly string[] | undefined, fallback: number): number {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : fallback;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolveRangeProgress(props: InputHTMLAttributes<HTMLInputElement>): number {
|
||||
const min = toNumber(props.min, 0);
|
||||
const max = toNumber(props.max, 100);
|
||||
const value = toNumber(props.value, min);
|
||||
const span = max - min;
|
||||
if (span <= 0) return 0;
|
||||
const progress = ((value - min) / span) * 100;
|
||||
return Math.min(100, Math.max(0, progress));
|
||||
}
|
||||
|
||||
const rangeInputClass = cn(
|
||||
'h-6 w-full cursor-pointer appearance-none bg-transparent [--range-track-h:0.5rem] [--range-thumb-h:1.25rem]',
|
||||
'focus-visible:outline-none focus-visible:[&::-webkit-slider-thumb]:ring-4 focus-visible:[&::-webkit-slider-thumb]:ring-accent/25',
|
||||
'focus-visible:[&::-moz-range-thumb]:ring-4 focus-visible:[&::-moz-range-thumb]:ring-accent/25',
|
||||
'disabled:cursor-not-allowed disabled:opacity-60',
|
||||
'[&::-webkit-slider-runnable-track]:h-[var(--range-track-h)] [&::-webkit-slider-runnable-track]:rounded-full',
|
||||
'[&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--secondary-accent)_0%,var(--secondary-accent)_var(--range-progress),color-mix(in_srgb,var(--offbase)_82%,var(--background))_var(--range-progress),color-mix(in_srgb,var(--offbase)_82%,var(--background))_100%)]',
|
||||
'[&::-webkit-slider-runnable-track]:shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_10%,transparent)]',
|
||||
'[&::-webkit-slider-thumb]:mt-[calc((var(--range-track-h)-var(--range-thumb-h))/2)] [&::-webkit-slider-thumb]:h-[var(--range-thumb-h)] [&::-webkit-slider-thumb]:w-[var(--range-thumb-h)] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full',
|
||||
'[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[color-mix(in_srgb,var(--background)_78%,white)]',
|
||||
'[&::-webkit-slider-thumb]:bg-accent',
|
||||
'[&::-webkit-slider-thumb]:transition-transform [&::-webkit-slider-thumb]:duration-150 [&::-webkit-slider-thumb]:ease-out',
|
||||
'active:[&::-webkit-slider-thumb]:scale-[1.07]',
|
||||
'[&::-moz-range-track]:h-[var(--range-track-h)] [&::-moz-range-track]:rounded-full [&::-moz-range-track]:border-0',
|
||||
'[&::-moz-range-track]:bg-[color-mix(in_srgb,var(--offbase)_82%,var(--background))]',
|
||||
'[&::-moz-range-progress]:h-[var(--range-track-h)] [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:border-0 [&::-moz-range-progress]:bg-secondary-accent',
|
||||
'[&::-moz-range-thumb]:h-[var(--range-thumb-h)] [&::-moz-range-thumb]:w-[var(--range-thumb-h)] [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2',
|
||||
'[&::-moz-range-thumb]:border-[color-mix(in_srgb,var(--background)_78%,white)] [&::-moz-range-thumb]:bg-accent',
|
||||
'[&::-moz-range-thumb]:transition-transform [&::-moz-range-thumb]:duration-150 [&::-moz-range-thumb]:ease-out',
|
||||
'active:[&::-moz-range-thumb]:scale-[1.07]',
|
||||
);
|
||||
|
||||
function rangeInputClassName(className?: string) {
|
||||
return cn(rangeInputClass, className);
|
||||
}
|
||||
|
||||
export function RangeInput({
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>) {
|
||||
const rangeStyle: RangeStyle = {
|
||||
...style,
|
||||
'--range-progress': `${resolveRangeProgress(props)}%`,
|
||||
};
|
||||
|
||||
return <input type="range" className={rangeInputClassName(className)} style={rangeStyle} {...props} />;
|
||||
}
|
||||
31
src/components/ui/search-field.tsx
Normal file
31
src/components/ui/search-field.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from './cn';
|
||||
import { motionColors } from './tokens';
|
||||
|
||||
export function SearchField({
|
||||
icon,
|
||||
className,
|
||||
inputClassName,
|
||||
...props
|
||||
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> & {
|
||||
icon?: ReactNode;
|
||||
inputClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-1.5 rounded-md border border-line bg-surface-sunken px-2 py-1',
|
||||
'focus-within:border-accent-line focus-within:ring-1 focus-within:ring-accent-line hover:border-accent-line',
|
||||
motionColors,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon ? <span className="shrink-0 text-soft">{icon}</span> : null}
|
||||
<input
|
||||
type="search"
|
||||
className={cn('min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-soft', inputClassName)}
|
||||
{...props}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
53
src/components/ui/section.tsx
Normal file
53
src/components/ui/section.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
action,
|
||||
variant = 'panel',
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
action?: ReactNode;
|
||||
variant?: 'panel' | 'flat';
|
||||
}) {
|
||||
if (variant === 'flat') {
|
||||
return (
|
||||
<section className="space-y-2 pb-3 border-b border-line-soft last:border-b-0">
|
||||
<SectionHeading title={title} subtitle={subtitle} action={action} />
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-line bg-surface overflow-hidden">
|
||||
<div className="px-3 py-2 bg-surface-solid border-b border-line-soft">
|
||||
<SectionHeading title={title} subtitle={subtitle} action={action} />
|
||||
</div>
|
||||
<div className="px-3 py-2 space-y-2">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeading({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
{subtitle ? <p className="text-xs text-soft mt-0.5">{subtitle}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/components/ui/segmented-control.tsx
Normal file
86
src/components/ui/segmented-control.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
'use client';
|
||||
|
||||
import { useRef, type KeyboardEvent, type ReactNode } from 'react';
|
||||
import { cn } from './cn';
|
||||
import { focusRing, motionColors } from './tokens';
|
||||
|
||||
export const segmentedGroupClass = 'grid gap-1 rounded-full border border-line bg-surface-sunken p-1';
|
||||
|
||||
export const segmentedButtonClass = (active: boolean) =>
|
||||
cn(
|
||||
'rounded-full px-2.5 py-1.5 text-xs font-medium',
|
||||
focusRing,
|
||||
motionColors,
|
||||
active ? 'bg-accent text-background' : 'text-soft hover:bg-accent-wash hover:text-foreground',
|
||||
);
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
className,
|
||||
}: {
|
||||
value: T;
|
||||
options: Array<{ value: T; label: ReactNode }>;
|
||||
onChange: (value: T) => void;
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const buttonRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
|
||||
const focusOption = (index: number) => {
|
||||
const option = options[index];
|
||||
if (!option) return;
|
||||
onChange(option.value);
|
||||
buttonRefs.current[index]?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
focusOption((index - 1 + options.length) % options.length);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
focusOption((index + 1) % options.length);
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
focusOption(0);
|
||||
break;
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
focusOption(options.length - 1);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div role="radiogroup" aria-label={ariaLabel} className={cn(segmentedGroupClass, className)}>
|
||||
{options.map((option, index) => {
|
||||
const active = value === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={(el) => { buttonRefs.current[index] = el; }}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => onChange(option.value)}
|
||||
onKeyDown={(event) => handleKeyDown(event, index)}
|
||||
className={segmentedButtonClass(active)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/components/ui/select.tsx
Normal file
142
src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
'use client';
|
||||
|
||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { cn } from './cn';
|
||||
import { CheckIcon, ChevronRightIcon } from '@/components/icons/Icons';
|
||||
|
||||
const listboxButtonClass =
|
||||
'relative w-full cursor-pointer rounded-md bg-surface-sunken border border-line py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent-line hover:bg-accent-wash transition-colors duration-fast ease-standard';
|
||||
|
||||
const listboxToolbarButtonClass =
|
||||
'inline-flex items-center rounded-md border border-line bg-surface px-2 py-1 text-xs text-foreground hover:border-accent-line hover:bg-accent-wash hover:text-accent transition-colors duration-fast ease-standard';
|
||||
|
||||
const listboxPopoverButtonClass =
|
||||
'inline-flex items-center rounded-md text-foreground hover:bg-accent-wash hover:text-accent focus:outline-none transition-colors duration-fast ease-standard';
|
||||
|
||||
const listboxPanelClass =
|
||||
'z-50 max-h-60 overflow-y-auto overscroll-contain rounded-md bg-surface p-1 shadow-elev-2 ring-1 ring-line focus:outline-none';
|
||||
|
||||
const listboxOptionsClass =
|
||||
cn(listboxPanelClass, 'w-[var(--button-width)] [--anchor-gap:0.25rem]');
|
||||
|
||||
const listboxCompactOptionsClass =
|
||||
'z-50 min-w-[8rem] rounded-md bg-surface p-1 shadow-elev-2 ring-1 ring-line focus:outline-none [--anchor-gap:0.25rem]';
|
||||
|
||||
const listboxOptionClass = (active: boolean, selected = false, inset: 'check' | 'none' = 'check') =>
|
||||
cn(
|
||||
'relative cursor-pointer select-none rounded-sm py-1.5 text-sm',
|
||||
inset === 'check' ? 'pl-9 pr-3' : 'px-2.5',
|
||||
selected ? 'bg-accent text-background font-medium' : active ? 'bg-accent-wash text-foreground' : 'text-foreground',
|
||||
);
|
||||
|
||||
const listboxCompactOptionClass = (active: boolean, selected = false) =>
|
||||
cn(
|
||||
'relative cursor-pointer select-none rounded-sm px-2 py-1 text-xs',
|
||||
active
|
||||
? 'bg-accent-wash text-accent'
|
||||
: selected
|
||||
? 'bg-surface-sunken text-accent font-medium'
|
||||
: 'text-foreground',
|
||||
);
|
||||
|
||||
export function SharedListboxButton({
|
||||
tone = 'default',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof ListboxButton> & {
|
||||
tone?: 'default' | 'toolbar' | 'popover' | 'unstyled';
|
||||
}) {
|
||||
const baseClass = tone === 'toolbar'
|
||||
? listboxToolbarButtonClass
|
||||
: tone === 'popover'
|
||||
? listboxPopoverButtonClass
|
||||
: tone === 'unstyled'
|
||||
? ''
|
||||
: listboxButtonClass;
|
||||
return (
|
||||
<ListboxButton className={cn(baseClass, className)} {...props}>
|
||||
{children}
|
||||
</ListboxButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function SharedListboxOptions({
|
||||
tone = 'default',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof ListboxOptions> & {
|
||||
tone?: 'default' | 'compact';
|
||||
}) {
|
||||
const baseClass = tone === 'compact' ? listboxCompactOptionsClass : listboxOptionsClass;
|
||||
return (
|
||||
<ListboxOptions className={cn(baseClass, className)} {...props}>
|
||||
{children}
|
||||
</ListboxOptions>
|
||||
);
|
||||
}
|
||||
|
||||
export function SharedListboxOption({
|
||||
tone = 'default',
|
||||
inset = 'check',
|
||||
itemClassName,
|
||||
children,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof ListboxOption>, 'className'> & {
|
||||
tone?: 'default' | 'compact';
|
||||
inset?: 'check' | 'none';
|
||||
itemClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<ListboxOption
|
||||
className={({ active, selected }: { active: boolean; selected: boolean }) => cn(
|
||||
tone === 'compact'
|
||||
? listboxCompactOptionClass(active, selected)
|
||||
: listboxOptionClass(active, selected, inset),
|
||||
itemClassName,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ListboxOption>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
}) {
|
||||
const activeOption = options.find((option) => option.value === value) ?? options[0];
|
||||
|
||||
return (
|
||||
<Listbox value={value} onChange={onChange}>
|
||||
<SharedListboxButton>
|
||||
<span>{activeOption?.label ?? 'Select'}</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-soft">
|
||||
<ChevronRightIcon className="h-4 w-4 rotate-90" aria-hidden="true" />
|
||||
</span>
|
||||
</SharedListboxButton>
|
||||
<SharedListboxOptions anchor="bottom">
|
||||
{options.map((option) => (
|
||||
<SharedListboxOption key={option.value} value={option.value}>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="absolute left-2 flex items-center text-accent">
|
||||
{selected ? <CheckIcon className="h-4 w-4" aria-hidden="true" /> : null}
|
||||
</span>
|
||||
<span>{option.label}</span>
|
||||
</>
|
||||
)}
|
||||
</SharedListboxOption>
|
||||
))}
|
||||
</SharedListboxOptions>
|
||||
</Listbox>
|
||||
);
|
||||
}
|
||||
214
src/components/ui/sidebar-nav.tsx
Normal file
214
src/components/ui/sidebar-nav.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { forwardRef, type AnchorHTMLAttributes, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode } from 'react';
|
||||
import { cn } from './cn';
|
||||
import { focusRing, motionColors } from './tokens';
|
||||
|
||||
export function SidebarNav({
|
||||
children,
|
||||
className,
|
||||
layout = 'stack',
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
layout?: 'stack' | 'grid';
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
layout === 'grid' ? 'grid grid-cols-2 gap-1' : 'flex flex-col gap-0.5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarNavGroup({
|
||||
children,
|
||||
action,
|
||||
className,
|
||||
isFirst = false,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
action?: ReactNode;
|
||||
isFirst?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'px-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-soft font-semibold leading-none flex items-center justify-between',
|
||||
isFirst ? 'pt-1.5' : 'pt-3',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{action ? <span className="inline-flex shrink-0 items-center leading-none">{action}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function sidebarNavItemClass({
|
||||
active = false,
|
||||
compact = false,
|
||||
isDropTarget = false,
|
||||
className,
|
||||
}: {
|
||||
active?: boolean;
|
||||
compact?: boolean;
|
||||
isDropTarget?: boolean;
|
||||
className?: string;
|
||||
} = {}) {
|
||||
return cn(
|
||||
'group w-full min-w-0 border text-left font-medium',
|
||||
'inline-flex items-center',
|
||||
focusRing,
|
||||
motionColors,
|
||||
compact ? 'gap-1.5 rounded-md px-2 py-1 text-xs' : 'gap-2 rounded-md px-2.5 py-1.5 text-sm',
|
||||
active
|
||||
? 'border-accent-line bg-surface-sunken text-accent'
|
||||
: 'border-transparent bg-transparent text-foreground hover:border-accent-line hover:bg-accent-wash hover:text-accent',
|
||||
isDropTarget ? 'ring-1 ring-accent-line' : '',
|
||||
className,
|
||||
);
|
||||
}
|
||||
|
||||
export function sidebarNavIconClass({
|
||||
active = false,
|
||||
compact = false,
|
||||
}: {
|
||||
active?: boolean;
|
||||
compact?: boolean;
|
||||
} = {}) {
|
||||
return cn(
|
||||
'shrink-0 inline-flex items-center justify-center transition-colors duration-base',
|
||||
compact ? 'h-4 w-4' : 'h-5 w-5',
|
||||
active ? 'text-accent' : 'text-soft group-hover:text-accent',
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarNavItemContent({
|
||||
active = false,
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
countClassName,
|
||||
trailing,
|
||||
compact = false,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean;
|
||||
icon?: ReactNode;
|
||||
label?: ReactNode;
|
||||
count?: number;
|
||||
countClassName?: string;
|
||||
trailing?: ReactNode;
|
||||
compact?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{icon ? (
|
||||
<span className={sidebarNavIconClass({ active, compact })}>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
{label ?? children ? <span className="min-w-0 flex-1 truncate">{label ?? children}</span> : null}
|
||||
{typeof count === 'number' && count > 0 ? (
|
||||
<span className={cn('text-[10px] text-soft tabular-nums transition-transform duration-base ease-standard', countClassName)}>
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
{trailing}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarNavItem({
|
||||
active = false,
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
countClassName,
|
||||
trailing,
|
||||
isDropTarget = false,
|
||||
className,
|
||||
compact = false,
|
||||
type = 'button',
|
||||
children,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
active?: boolean;
|
||||
icon?: ReactNode;
|
||||
label?: ReactNode;
|
||||
count?: number;
|
||||
countClassName?: string;
|
||||
trailing?: ReactNode;
|
||||
isDropTarget?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={sidebarNavItemClass({ active, compact, isDropTarget, className })}
|
||||
{...props}
|
||||
>
|
||||
<SidebarNavItemContent
|
||||
active={active}
|
||||
compact={compact}
|
||||
icon={icon}
|
||||
label={label}
|
||||
count={count}
|
||||
countClassName={countClassName}
|
||||
trailing={trailing}
|
||||
>
|
||||
{children}
|
||||
</SidebarNavItemContent>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export const SidebarNavLink = forwardRef<HTMLAnchorElement, AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
active?: boolean;
|
||||
icon?: ReactNode;
|
||||
label?: ReactNode;
|
||||
count?: number;
|
||||
countClassName?: string;
|
||||
trailing?: ReactNode;
|
||||
isDropTarget?: boolean;
|
||||
compact?: boolean;
|
||||
}>(function SidebarNavLink({
|
||||
active = false,
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
countClassName,
|
||||
trailing,
|
||||
isDropTarget = false,
|
||||
className,
|
||||
compact = false,
|
||||
children,
|
||||
...props
|
||||
}, ref) {
|
||||
return (
|
||||
<a
|
||||
ref={ref}
|
||||
className={sidebarNavItemClass({ active, compact, isDropTarget, className })}
|
||||
{...props}
|
||||
>
|
||||
<SidebarNavItemContent
|
||||
active={active}
|
||||
compact={compact}
|
||||
icon={icon}
|
||||
label={label}
|
||||
count={count}
|
||||
countClassName={countClassName}
|
||||
trailing={trailing}
|
||||
>
|
||||
{children}
|
||||
</SidebarNavItemContent>
|
||||
</a>
|
||||
);
|
||||
});
|
||||
17
src/components/ui/sidebar.tsx
Normal file
17
src/components/ui/sidebar.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from './cn';
|
||||
|
||||
export function Sidebar({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLElement> & { children: ReactNode }) {
|
||||
return (
|
||||
<aside
|
||||
className={cn('rounded-lg border border-line bg-surface text-foreground shadow-elev-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
92
src/components/ui/surface.tsx
Normal file
92
src/components/ui/surface.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from './cn';
|
||||
import { variants } from './variants';
|
||||
import { motionSurface } from './tokens';
|
||||
|
||||
export type SurfaceTone = 'default' | 'solid' | 'sunken' | 'transparent';
|
||||
export type SurfaceElevation = 'none' | '1' | '2' | '3';
|
||||
export type SurfaceRadius = 'sm' | 'md' | 'lg' | 'pill';
|
||||
|
||||
export const surfaceStyles = variants({
|
||||
base: 'border text-foreground',
|
||||
variants: {
|
||||
tone: {
|
||||
default: 'border-line bg-surface',
|
||||
solid: 'border-line bg-surface-solid',
|
||||
sunken: 'border-line bg-surface-sunken',
|
||||
transparent: 'border-transparent bg-transparent',
|
||||
},
|
||||
elevation: {
|
||||
none: '',
|
||||
'1': 'shadow-elev-1',
|
||||
'2': 'shadow-elev-2',
|
||||
'3': 'shadow-elev-3',
|
||||
},
|
||||
radius: {
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
pill: 'rounded-pill',
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
tone: 'default',
|
||||
elevation: 'none',
|
||||
radius: 'lg',
|
||||
},
|
||||
});
|
||||
|
||||
export function Surface({
|
||||
children,
|
||||
className,
|
||||
tone = 'default',
|
||||
elevation = 'none',
|
||||
radius = 'lg',
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
tone?: SurfaceTone;
|
||||
elevation?: SurfaceElevation;
|
||||
radius?: SurfaceRadius;
|
||||
}) {
|
||||
return (
|
||||
<div className={surfaceStyles({ tone, elevation, radius, className })} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Panel({
|
||||
children,
|
||||
className,
|
||||
elevation = '1',
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
elevation?: SurfaceElevation;
|
||||
}) {
|
||||
return (
|
||||
<Surface elevation={elevation} className={cn('overflow-hidden', className)} {...props}>
|
||||
{children}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
interactive = false,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
interactive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Surface
|
||||
className={cn('px-3 py-2', interactive && motionSurface, className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
72
src/components/ui/switch.tsx
Normal file
72
src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from './cn';
|
||||
import { focusRing, motionColors } from './tokens';
|
||||
|
||||
export type SwitchSize = 'sm' | 'md';
|
||||
|
||||
const SWITCH_SIZE: Record<SwitchSize, { track: string; thumb: string; on: string; off: string }> = {
|
||||
sm: {
|
||||
track: 'h-4 w-7',
|
||||
thumb: 'h-3 w-3',
|
||||
on: 'translate-x-3',
|
||||
off: 'translate-x-0.5',
|
||||
},
|
||||
md: {
|
||||
track: 'h-5 w-9',
|
||||
thumb: 'h-4 w-4',
|
||||
on: 'translate-x-4',
|
||||
off: 'translate-x-0.5',
|
||||
},
|
||||
};
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
size = 'md',
|
||||
ariaLabel,
|
||||
ariaLabelledBy,
|
||||
ariaDescribedBy,
|
||||
className,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
size?: SwitchSize;
|
||||
ariaLabel?: string;
|
||||
ariaLabelledBy?: string;
|
||||
ariaDescribedBy?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const s = SWITCH_SIZE[size];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'relative inline-flex shrink-0 cursor-pointer items-center rounded-full border border-line disabled:cursor-not-allowed disabled:opacity-50',
|
||||
checked ? 'bg-accent' : 'bg-line-strong',
|
||||
s.track,
|
||||
focusRing,
|
||||
motionColors,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'pointer-events-none inline-block rounded-full bg-surface shadow-elev-1 ring-0 transition-transform duration-fast ease-standard',
|
||||
checked ? s.on : s.off,
|
||||
s.thumb,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
3
src/components/ui/tokens.ts
Normal file
3
src/components/ui/tokens.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export const focusRing = 'focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background';
|
||||
export const motionColors = 'transition-colors duration-fast ease-standard';
|
||||
export const motionSurface = 'transition-[transform,box-shadow,border-color,background-color] duration-base ease-standard';
|
||||
83
src/components/ui/toolbar.tsx
Normal file
83
src/components/ui/toolbar.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from './cn';
|
||||
import { variants } from './variants';
|
||||
import { motionColors } from './tokens';
|
||||
|
||||
export const toolbarButtonStyles = variants({
|
||||
base: cn('inline-flex items-center rounded-md border px-2 py-1 text-xs', motionColors),
|
||||
variants: {
|
||||
active: {
|
||||
true: 'border-accent-line bg-surface-sunken text-accent',
|
||||
false: 'border-line bg-surface text-foreground hover:border-accent-line hover:bg-accent-wash hover:text-accent',
|
||||
},
|
||||
},
|
||||
defaults: {
|
||||
active: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
export function Toolbar({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
|
||||
return (
|
||||
<div className={cn('sticky top-0 z-40 w-full border-b border-line-soft bg-surface', className)} {...props}>
|
||||
<div className="px-2 sm:px-3 py-1 min-h-10 flex items-center gap-1.5 sm:gap-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarButton({
|
||||
active = false,
|
||||
className,
|
||||
children,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
active?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button type={type} className={toolbarButtonStyles({ active: active ? 'true' : 'false', className })} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarGroup({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
|
||||
return (
|
||||
<div className={cn('inline-flex shrink-0 items-center gap-0.5 rounded-md border border-line bg-surface p-0.5', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarSegment({
|
||||
active = false,
|
||||
className,
|
||||
children,
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
active?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={cn(
|
||||
'inline-flex h-6 items-center justify-center rounded-sm text-xs transition-colors duration-base ease-standard',
|
||||
active ? 'bg-surface-sunken text-accent' : 'text-soft hover:bg-accent-wash hover:text-accent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
25
src/components/ui/variants.ts
Normal file
25
src/components/ui/variants.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { cn, type ClassValue } from './cn';
|
||||
|
||||
type VariantGroups = Record<string, Record<string, string>>;
|
||||
type VariantSelection<T extends VariantGroups> = {
|
||||
[K in keyof T]?: keyof T[K] | null | undefined;
|
||||
};
|
||||
|
||||
export function variants<T extends VariantGroups>({
|
||||
base,
|
||||
variants: groups,
|
||||
defaults,
|
||||
}: {
|
||||
base?: string;
|
||||
variants: T;
|
||||
defaults?: VariantSelection<T>;
|
||||
}) {
|
||||
return (selection: VariantSelection<T> & { className?: ClassValue } = {}) => {
|
||||
const resolved = Object.keys(groups).map((key) => {
|
||||
const groupKey = key as keyof T;
|
||||
const value = selection[groupKey] ?? defaults?.[groupKey];
|
||||
return value ? groups[groupKey][value as keyof T[typeof groupKey]] : '';
|
||||
});
|
||||
return cn(base, ...resolved, selection.className);
|
||||
};
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
|||
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
||||
import { DotsVerticalIcon, ChevronLeftIcon, ChevronRightIcon } from '@/components/icons/Icons';
|
||||
import type { EpubDocumentState } from '@/app/(app)/epub/[id]/useEpubDocument';
|
||||
import { ToolbarButton } from '@/components/ui';
|
||||
|
||||
const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), {
|
||||
ssr: false,
|
||||
|
|
@ -158,42 +159,40 @@ export function EPUBViewer({ className = '', epubState }: EPUBViewerProps) {
|
|||
|
||||
return (
|
||||
<div className={`h-full flex flex-col relative z-0 ${className}`} ref={containerRef}>
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b border-offbase bg-base text-xs text-muted">
|
||||
<div className="flex items-center justify-between px-2 py-1 border-b border-line-soft bg-surface text-xs text-soft">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<ToolbarButton
|
||||
type="button"
|
||||
onClick={() => setIsTocOpen(open => !open)}
|
||||
className="inline-flex items-center py-1 px-1 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
|
||||
aria-label={isTocOpen ? 'Hide chapters' : 'Show chapters'}
|
||||
className="px-1"
|
||||
>
|
||||
<DotsVerticalIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
type="button"
|
||||
onClick={() => handleLocationChanged('prev')}
|
||||
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
|
||||
aria-label="Previous section"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
{currDocPages !== undefined && typeof currDocPage === 'number' && (
|
||||
<span className="px-2 tabular-nums">
|
||||
{currDocPage} / {currDocPages}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
<ToolbarButton
|
||||
type="button"
|
||||
onClick={() => handleLocationChanged('next')}
|
||||
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
|
||||
aria-label="Next section"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
{isTocOpen && tocRef.current && tocRef.current.length > 0 && (
|
||||
<div className="border-b border-offbase bg-background text-xs overflow-y-auto max-h-64 p-2">
|
||||
<div className="font-semibold text-muted pb-1">Skip to chapters</div>
|
||||
<div className="border-b border-line-soft bg-background text-xs overflow-y-auto max-h-64 p-2">
|
||||
<div className="font-semibold text-soft pb-1">Skip to chapters</div>
|
||||
<div className="flex flex-wrap gap-1 w-full">
|
||||
{tocRef.current.map((item, index) => (
|
||||
<button
|
||||
|
|
@ -204,8 +203,8 @@ export function EPUBViewer({ className = '', epubState }: EPUBViewerProps) {
|
|||
setIsTocOpen(false);
|
||||
}}
|
||||
className="
|
||||
px-2 py-1 rounded-md font-medium text-foreground text-center bg-base
|
||||
hover:bg-offbase hover:text-accent transition-colors duration-150
|
||||
px-2 py-1 rounded-md font-medium text-foreground text-center bg-surface
|
||||
hover:bg-accent-wash hover:text-accent transition-colors duration-fast
|
||||
whitespace-nowrap
|
||||
flex-1 min-w-[140px]
|
||||
"
|
||||
|
|
|
|||
|
|
@ -480,7 +480,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
|
|||
return (
|
||||
<div
|
||||
key={`${block.id}:${fragment.page}:${fragment.readingOrder}`}
|
||||
className="absolute border border-accent/70 rounded-[2px]"
|
||||
className="absolute border border-accent-line rounded-sm"
|
||||
style={{
|
||||
left: `${leftPct}%`,
|
||||
top: `${topPct}%`,
|
||||
|
|
@ -536,7 +536,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
|
|||
pageNumber={i + 1}
|
||||
renderAnnotationLayer={true}
|
||||
renderTextLayer={i + 1 === currDocPage}
|
||||
className="shadow-lg"
|
||||
className="shadow-elev-2"
|
||||
scale={currentScale()}
|
||||
onRenderSuccess={() => {
|
||||
lastRenderedLayoutKeyRef.current = layoutKey;
|
||||
|
|
@ -562,7 +562,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
|
|||
pageNumber={leftPage}
|
||||
renderAnnotationLayer={true}
|
||||
renderTextLayer={leftPage === currDocPage}
|
||||
className="shadow-lg"
|
||||
className="shadow-elev-2"
|
||||
scale={currentScale()}
|
||||
onRenderSuccess={() => {
|
||||
lastRenderedLayoutKeyRef.current = layoutKey;
|
||||
|
|
@ -584,7 +584,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
|
|||
pageNumber={rightPage}
|
||||
renderAnnotationLayer={true}
|
||||
renderTextLayer={rightPage === currDocPage}
|
||||
className="shadow-lg"
|
||||
className="shadow-elev-2"
|
||||
scale={currentScale()}
|
||||
onRenderSuccess={() => {
|
||||
lastRenderedLayoutKeyRef.current = layoutKey;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,39 @@ export default {
|
|||
accent: "var(--accent)",
|
||||
"secondary-accent": "var(--secondary-accent)",
|
||||
muted: "var(--muted)",
|
||||
surface: "var(--surface)",
|
||||
"surface-solid": "var(--surface-solid)",
|
||||
"surface-sunken": "var(--surface-sunken)",
|
||||
line: "var(--line)",
|
||||
"line-soft": "var(--line-soft)",
|
||||
"line-strong": "var(--line-strong)",
|
||||
soft: "var(--soft)",
|
||||
faint: "var(--faint)",
|
||||
"accent-wash": "var(--accent-wash)",
|
||||
"accent-line": "var(--accent-line)",
|
||||
"accent-strong": "var(--accent-strong)",
|
||||
danger: "var(--danger)",
|
||||
"danger-strong": "var(--danger-strong)",
|
||||
"danger-wash": "var(--danger-wash)",
|
||||
},
|
||||
boxShadow: {
|
||||
"elev-1": "var(--elev-1)",
|
||||
"elev-2": "var(--elev-2)",
|
||||
"elev-3": "var(--elev-3)",
|
||||
},
|
||||
borderRadius: {
|
||||
sm: "6px",
|
||||
md: "10px",
|
||||
lg: "14px",
|
||||
pill: "999px",
|
||||
},
|
||||
transitionDuration: {
|
||||
fast: "var(--dur-fast)",
|
||||
base: "var(--dur-base)",
|
||||
slow: "var(--dur-slow)",
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
standard: "var(--ease)",
|
||||
},
|
||||
animation: {
|
||||
'spin-slow': 'spin 2s linear infinite',
|
||||
|
|
|
|||
|
|
@ -39,14 +39,18 @@ test.describe('Accessibility smoke', () => {
|
|||
// Open the confirm dialog by clicking the row delete button
|
||||
await page.getByRole('button', { name: /^Delete sample\.pdf$/i }).first().click();
|
||||
|
||||
// Title and dialog role visible
|
||||
// Title and dialog semantics are present
|
||||
const heading = page.getByRole('heading', { name: 'Delete Document' });
|
||||
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||
const dialog = heading.locator('xpath=ancestor::*[@role="dialog"][1]');
|
||||
await expect(dialog).toBeVisible();
|
||||
const dialog = page.getByRole('dialog', { name: 'Delete Document' }).first();
|
||||
await expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
|
||||
// The visible panel content is rendered and includes destructive action
|
||||
const panel = page.getByTestId('confirm-dialog-panel').first();
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
// Has a destructive action (Delete)
|
||||
await expect(dialog.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
await expect(panel.getByRole('button', { name: /^Delete$/ })).toBeVisible();
|
||||
|
||||
// Close with Escape to avoid deleting test data
|
||||
await page.keyboard.press('Escape');
|
||||
|
|
|
|||
|
|
@ -497,37 +497,44 @@ export async function expectProcessingTransition(page: Page) {
|
|||
|
||||
// Expect navigator.mediaSession.playbackState to equal given state
|
||||
export async function expectMediaState(page: Page, state: 'playing' | 'paused') {
|
||||
// WebKit (and sometimes other engines) may not reliably update navigator.mediaSession.playbackState.
|
||||
// Fallback heuristics:
|
||||
// 1. Prefer mediaSession if it matches desired state.
|
||||
// 2. Otherwise inspect any <audio> element: use paused flag and currentTime progression.
|
||||
// 3. Allow short grace period for first frame to advance.
|
||||
// 4. If neither detectable, keep polling until timeout.
|
||||
await page.waitForFunction((desired) => {
|
||||
try {
|
||||
const msState = (navigator.mediaSession && navigator.mediaSession.playbackState) || '';
|
||||
if (msState === desired) return true;
|
||||
// Engines can intermittently miss mediaSession updates. Accept either:
|
||||
// - expected UI control state (Pause button for playing, Play button for paused), or
|
||||
// - underlying media state from mediaSession/audio element signals.
|
||||
const desiredButtonName = state === 'playing' ? 'Pause' : 'Play';
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const uiMatches = await page
|
||||
.getByRole('button', { name: desiredButtonName })
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
if (uiMatches) return true;
|
||||
|
||||
const audio: HTMLAudioElement | null = document.querySelector('audio');
|
||||
if (audio) {
|
||||
// Track advancement by storing last time on the element dataset
|
||||
const last = parseFloat(audio.dataset.lastTime || '0');
|
||||
const curr = audio.currentTime;
|
||||
audio.dataset.lastTime = String(curr);
|
||||
return page.evaluate((desired) => {
|
||||
try {
|
||||
const msState = (navigator.mediaSession && navigator.mediaSession.playbackState) || '';
|
||||
if (msState === desired) return true;
|
||||
|
||||
if (desired === 'playing') {
|
||||
// Consider playing if not paused AND time has advanced at least a tiny amount
|
||||
if (!audio.paused && curr > 0 && curr > last) return true;
|
||||
} else {
|
||||
// paused target
|
||||
if (audio.paused) return true;
|
||||
const audio: HTMLAudioElement | null = document.querySelector('audio');
|
||||
if (audio) {
|
||||
const w = window as Window & { __openreaderLastAudioTime?: number };
|
||||
const last = w.__openreaderLastAudioTime ?? -1;
|
||||
const curr = audio.currentTime;
|
||||
w.__openreaderLastAudioTime = curr;
|
||||
|
||||
if (desired === 'playing') {
|
||||
if (!audio.paused && curr > 0 && curr > last) return true;
|
||||
} else if (audio.paused) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, state);
|
||||
}, state);
|
||||
}, { timeout: 45000 })
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
// Use Navigator to go to a specific page number (PDF)
|
||||
|
|
|
|||
Loading…
Reference in a new issue