refactor(ui): enforce shared control primitives
This commit is contained in:
parent
8071ae1a61
commit
922bc32462
22 changed files with 234 additions and 183 deletions
|
|
@ -9,7 +9,7 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
import { showPrivacyModal } from '@/components/PrivacyModal';
|
import { showPrivacyModal } from '@/components/PrivacyModal';
|
||||||
import { GithubIcon } from '@/components/icons/Icons';
|
import { GithubIcon } from '@/components/icons/Icons';
|
||||||
import { LoadingSpinner } from '@/components/Spinner';
|
import { LoadingSpinner } from '@/components/Spinner';
|
||||||
import { Button, Field, Input, Surface } from '@/components/ui';
|
import { Button, Checkbox, Field, InlineButton, Input, Surface } from '@/components/ui';
|
||||||
|
|
||||||
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
|
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
@ -169,11 +169,9 @@ function SignInContent() {
|
||||||
|
|
||||||
{/* Remember Me */}
|
{/* Remember Me */}
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
checked={rememberMe}
|
checked={rememberMe}
|
||||||
onChange={(e) => setRememberMe(e.target.checked)}
|
onChange={(e) => setRememberMe(e.target.checked)}
|
||||||
className="rounded border-muted text-accent focus:ring-accent-line"
|
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-foreground">Remember me</span>
|
<span className="text-sm text-foreground">Remember me</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -238,12 +236,9 @@ function SignInContent() {
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-soft">
|
<p className="text-xs text-soft">
|
||||||
By signing in, you agree to our{' '}
|
By signing in, you agree to our{' '}
|
||||||
<button
|
<InlineButton onClick={() => showPrivacyModal()}>
|
||||||
onClick={() => showPrivacyModal()}
|
|
||||||
className="underline hover:text-foreground"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</button>
|
</InlineButton>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
import { showPrivacyModal } from '@/components/PrivacyModal';
|
import { showPrivacyModal } from '@/components/PrivacyModal';
|
||||||
import { LoadingSpinner } from '@/components/Spinner';
|
import { LoadingSpinner } from '@/components/Spinner';
|
||||||
import { Button, Field, IconButton, Input, Surface } from '@/components/ui';
|
import { Button, Field, IconButton, InlineButton, Input, Surface } from '@/components/ui';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function SignUpPage() {
|
export default function SignUpPage() {
|
||||||
|
|
@ -241,12 +241,9 @@ export default function SignUpPage() {
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-soft">
|
<p className="text-xs text-soft">
|
||||||
By creating an account, you agree to our{' '}
|
By creating an account, you agree to our{' '}
|
||||||
<button
|
<InlineButton onClick={() => showPrivacyModal()}>
|
||||||
onClick={() => showPrivacyModal()}
|
|
||||||
className="underline hover:text-foreground"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</button>
|
</InlineButton>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { Transition, Menu, MenuButton } from '@headlessui/react';
|
|
||||||
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
||||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
import { ProgressCard } from '@/components/ProgressCard';
|
import { ProgressCard } from '@/components/ProgressCard';
|
||||||
|
|
@ -14,7 +13,7 @@ import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
import { getTtsLanguageCompatibilityWarnings, resolveTtsLanguage } from '@/lib/shared/language';
|
import { getTtsLanguageCompatibilityWarnings, resolveTtsLanguage } from '@/lib/shared/language';
|
||||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||||
import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, Select } from '@/components/ui';
|
import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, MenuRoot, MenuTransition, MenuTrigger, RangeInput, Select } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
getAudiobookStatus,
|
getAudiobookStatus,
|
||||||
deleteAudiobookChapter,
|
deleteAudiobookChapter,
|
||||||
|
|
@ -762,23 +761,15 @@ export function AudiobookExportModal({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
|
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
|
||||||
<Menu as="div" className="relative inline-block text-left">
|
<MenuRoot as="div" className="relative inline-block text-left">
|
||||||
<MenuButton
|
<MenuTrigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
size="sm"
|
size="sm"
|
||||||
title="Chapter actions"
|
title="Chapter actions"
|
||||||
>
|
>
|
||||||
<DotsVerticalIcon className="h-5 w-5" />
|
<DotsVerticalIcon className="h-5 w-5" />
|
||||||
</MenuButton>
|
</MenuTrigger>
|
||||||
<Transition
|
<MenuTransition>
|
||||||
as={Fragment}
|
|
||||||
enter="transition ease-standard duration-fast"
|
|
||||||
enterFrom="transform opacity-0 scale-95"
|
|
||||||
enterTo="transform opacity-100 scale-100"
|
|
||||||
leave="transition ease-standard duration-fast"
|
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
|
||||||
leaveTo="transform opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<MenuItemsSurface
|
<MenuItemsSurface
|
||||||
anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }}
|
anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }}
|
||||||
portal
|
portal
|
||||||
|
|
@ -822,8 +813,8 @@ export function AudiobookExportModal({
|
||||||
)}
|
)}
|
||||||
</MenuItemsSurface>
|
</MenuItemsSurface>
|
||||||
{/* end of menu items */}
|
{/* end of menu items */}
|
||||||
</Transition>
|
</MenuTransition>
|
||||||
</Menu>
|
</MenuRoot>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, useState, useEffect, useCallback } from 'react';
|
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
import { Popover, PopoverButton } from '@headlessui/react';
|
|
||||||
import { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext';
|
import { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext';
|
||||||
import { PaletteIcon } from '@/components/icons/Icons';
|
import { PaletteIcon } from '@/components/icons/Icons';
|
||||||
import { IconButton, Input, PopoverSurface } from '@/components/ui';
|
import { IconButton, Input, PopoverIconTrigger, PopoverRoot, PopoverSurface } from '@/components/ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curated swatch palettes per color role, sourced from existing themes
|
* Curated swatch palettes per color role, sourced from existing themes
|
||||||
|
|
@ -78,8 +77,8 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
|
||||||
const swatches = ROLE_SWATCHES[field];
|
const swatches = ROLE_SWATCHES[field];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="relative flex items-center">
|
<PopoverRoot className="relative flex items-center">
|
||||||
<PopoverButton as={IconButton} size="sm" className="group rounded-full p-0" aria-label={`Pick ${label} color`}>
|
<PopoverIconTrigger size="sm" className="group rounded-full p-0" aria-label={`Pick ${label} color`}>
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded-full border-2 transition duration-fast group-focus-visible:ring-2 group-focus-visible:ring-offset-1"
|
className="w-6 h-6 rounded-full border-2 transition duration-fast group-focus-visible:ring-2 group-focus-visible:ring-offset-1"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -87,7 +86,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
|
||||||
borderColor: isLightColor(value) ? '#00000022' : '#ffffff22',
|
borderColor: isLightColor(value) ? '#00000022' : '#ffffff22',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverButton>
|
</PopoverIconTrigger>
|
||||||
|
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
anchor="bottom start"
|
anchor="bottom start"
|
||||||
|
|
@ -171,6 +170,6 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</Popover>
|
</PopoverRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { updateAppConfig } from '@/lib/client/dexie';
|
import { updateAppConfig } from '@/lib/client/dexie';
|
||||||
import { Button, ModalFrame, ModalTitle } from '@/components/ui';
|
import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
|
||||||
|
|
||||||
interface PrivacyModalProps {
|
interface PrivacyModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -78,13 +78,11 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
|
||||||
<div className="mt-6 space-y-4">
|
<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 items-start gap-3 rounded-lg border border-line p-3 bg-surface-sunken">
|
||||||
<div className="flex h-6 items-center">
|
<div className="flex h-6 items-center">
|
||||||
<input
|
<Checkbox
|
||||||
data-testid="privacy-agree-checkbox"
|
data-testid="privacy-agree-checkbox"
|
||||||
id="privacy-agree"
|
id="privacy-agree"
|
||||||
type="checkbox"
|
|
||||||
checked={agreed}
|
checked={agreed}
|
||||||
onChange={(e) => setAgreed(e.target.checked)}
|
onChange={(e) => setAgreed(e.target.checked)}
|
||||||
className="h-4 w-4 rounded border-line text-accent focus:ring-accent-line bg-surface"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm leading-6">
|
<div className="text-sm leading-6">
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,10 @@ import {
|
||||||
ChoiceTile,
|
ChoiceTile,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
|
Textarea,
|
||||||
ModalFrame,
|
ModalFrame,
|
||||||
ModalTitle,
|
ModalTitle,
|
||||||
Select,
|
Select,
|
||||||
inputClass,
|
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
@ -697,7 +697,6 @@ export function SettingsModal({
|
||||||
value={localBaseUrl}
|
value={localBaseUrl}
|
||||||
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
|
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
|
||||||
placeholder="Using environment variable"
|
placeholder="Using environment variable"
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -713,7 +712,6 @@ export function SettingsModal({
|
||||||
value={localApiKey}
|
value={localApiKey}
|
||||||
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
||||||
placeholder="Using environment variable"
|
placeholder="Using environment variable"
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -774,7 +772,6 @@ export function SettingsModal({
|
||||||
setModelValue(e.target.value);
|
setModelValue(e.target.value);
|
||||||
}}
|
}}
|
||||||
placeholder="Enter custom model name"
|
placeholder="Enter custom model name"
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -783,11 +780,11 @@ export function SettingsModal({
|
||||||
{providerModelPolicy.supportsInstructions && (
|
{providerModelPolicy.supportsInstructions && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className={fieldLabelClass}>TTS Instructions</label>
|
<label className={fieldLabelClass}>TTS Instructions</label>
|
||||||
<textarea
|
<Textarea
|
||||||
value={localTTSInstructions}
|
value={localTTSInstructions}
|
||||||
onChange={(e) => setLocalTTSInstructions(e.target.value)}
|
onChange={(e) => setLocalTTSInstructions(e.target.value)}
|
||||||
placeholder="Enter instructions for the TTS model"
|
placeholder="Enter instructions for the TTS model"
|
||||||
className={`${inputClass} h-24 resize-none`}
|
className="h-24 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ import {
|
||||||
Badge,
|
Badge,
|
||||||
Section,
|
Section,
|
||||||
ToggleRow,
|
ToggleRow,
|
||||||
inputClass,
|
|
||||||
Select,
|
Select,
|
||||||
Button,
|
Button,
|
||||||
|
Input,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||||
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
|
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
|
||||||
|
|
@ -274,11 +274,10 @@ export function AdminFeaturesPanel() {
|
||||||
<label className="text-xs font-medium text-foreground">Anonymous per-user daily limit</label>
|
<label className="text-xs font-medium text-foreground">Anonymous per-user daily limit</label>
|
||||||
{renderSource('ttsDailyLimitAnonymous')}
|
{renderSource('ttsDailyLimitAnonymous')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsDailyLimitAnonymous ?? '')}
|
value={String(draft.ttsDailyLimitAnonymous ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -288,11 +287,10 @@ export function AdminFeaturesPanel() {
|
||||||
<label className="text-xs font-medium text-foreground">Authenticated per-user daily limit</label>
|
<label className="text-xs font-medium text-foreground">Authenticated per-user daily limit</label>
|
||||||
{renderSource('ttsDailyLimitAuthenticated')}
|
{renderSource('ttsDailyLimitAuthenticated')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsDailyLimitAuthenticated ?? '')}
|
value={String(draft.ttsDailyLimitAuthenticated ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -302,11 +300,10 @@ export function AdminFeaturesPanel() {
|
||||||
<label className="text-xs font-medium text-foreground">Anonymous IP daily backstop</label>
|
<label className="text-xs font-medium text-foreground">Anonymous IP daily backstop</label>
|
||||||
{renderSource('ttsIpDailyLimitAnonymous')}
|
{renderSource('ttsIpDailyLimitAnonymous')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsIpDailyLimitAnonymous ?? '')}
|
value={String(draft.ttsIpDailyLimitAnonymous ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -316,11 +313,10 @@ export function AdminFeaturesPanel() {
|
||||||
<label className="text-xs font-medium text-foreground">Authenticated IP daily backstop</label>
|
<label className="text-xs font-medium text-foreground">Authenticated IP daily backstop</label>
|
||||||
{renderSource('ttsIpDailyLimitAuthenticated')}
|
{renderSource('ttsIpDailyLimitAuthenticated')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsIpDailyLimitAuthenticated ?? '')}
|
value={String(draft.ttsIpDailyLimitAuthenticated ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -343,11 +339,10 @@ export function AdminFeaturesPanel() {
|
||||||
<label className="text-xs font-medium text-foreground">Burst limit (parses)</label>
|
<label className="text-xs font-medium text-foreground">Burst limit (parses)</label>
|
||||||
{renderSource('computeParseBurstMax')}
|
{renderSource('computeParseBurstMax')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.computeParseBurstMax ?? '')}
|
value={String(draft.computeParseBurstMax ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('computeParseBurstMax', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('computeParseBurstMax', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -357,11 +352,10 @@ export function AdminFeaturesPanel() {
|
||||||
<label className="text-xs font-medium text-foreground">Burst window (seconds)</label>
|
<label className="text-xs font-medium text-foreground">Burst window (seconds)</label>
|
||||||
{renderSource('computeParseBurstWindowSec')}
|
{renderSource('computeParseBurstWindowSec')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.computeParseBurstWindowSec ?? '')}
|
value={String(draft.computeParseBurstWindowSec ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('computeParseBurstWindowSec', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('computeParseBurstWindowSec', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -371,11 +365,10 @@ export function AdminFeaturesPanel() {
|
||||||
<label className="text-xs font-medium text-foreground">Sustained limit (parses)</label>
|
<label className="text-xs font-medium text-foreground">Sustained limit (parses)</label>
|
||||||
{renderSource('computeParseSustainedMax')}
|
{renderSource('computeParseSustainedMax')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.computeParseSustainedMax ?? '')}
|
value={String(draft.computeParseSustainedMax ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('computeParseSustainedMax', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('computeParseSustainedMax', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -385,11 +378,10 @@ export function AdminFeaturesPanel() {
|
||||||
<label className="text-xs font-medium text-foreground">Sustained window (seconds)</label>
|
<label className="text-xs font-medium text-foreground">Sustained window (seconds)</label>
|
||||||
{renderSource('computeParseSustainedWindowSec')}
|
{renderSource('computeParseSustainedWindowSec')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.computeParseSustainedWindowSec ?? '')}
|
value={String(draft.computeParseSustainedWindowSec ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('computeParseSustainedWindowSec', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('computeParseSustainedWindowSec', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -405,13 +397,13 @@ export function AdminFeaturesPanel() {
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 self-start pl-1.5">{renderSource('maxUploadMb')}</div>
|
<div className="shrink-0 self-start pl-1.5">{renderSource('maxUploadMb')}</div>
|
||||||
<div className="shrink-0 flex items-center gap-1.5">
|
<div className="shrink-0 flex items-center gap-1.5">
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
aria-label="Max upload size in megabytes"
|
aria-label="Max upload size in megabytes"
|
||||||
className="w-20 rounded-md bg-background border border-offbase px-2.5 py-1.5 text-sm text-foreground text-right focus:outline-none focus:ring-2 focus:ring-accent"
|
className="w-20 text-right"
|
||||||
value={String(draft.maxUploadMb ?? '')}
|
value={String(draft.maxUploadMb ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('maxUploadMb', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('maxUploadMb', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -436,9 +428,8 @@ export function AdminFeaturesPanel() {
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0">{renderSource('changelogFeedUrl')}</div>
|
<div className="shrink-0">{renderSource('changelogFeedUrl')}</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.changelogFeedUrl ?? '')}
|
value={String(draft.changelogFeedUrl ?? '')}
|
||||||
onChange={(event) => updateDraft('changelogFeedUrl', event.target.value)}
|
onChange={(event) => updateDraft('changelogFeedUrl', event.target.value)}
|
||||||
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
|
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
|
||||||
|
|
@ -481,11 +472,10 @@ export function AdminFeaturesPanel() {
|
||||||
<label className="text-xs font-medium text-foreground">Retry attempts</label>
|
<label className="text-xs font-medium text-foreground">Retry attempts</label>
|
||||||
{renderSource('ttsUpstreamMaxRetries')}
|
{renderSource('ttsUpstreamMaxRetries')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsUpstreamMaxRetries ?? '')}
|
value={String(draft.ttsUpstreamMaxRetries ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsUpstreamMaxRetries', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('ttsUpstreamMaxRetries', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -495,11 +485,10 @@ export function AdminFeaturesPanel() {
|
||||||
<label className="text-xs font-medium text-foreground">Upstream timeout (ms)</label>
|
<label className="text-xs font-medium text-foreground">Upstream timeout (ms)</label>
|
||||||
{renderSource('ttsUpstreamTimeoutMs')}
|
{renderSource('ttsUpstreamTimeoutMs')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsUpstreamTimeoutMs ?? '')}
|
value={String(draft.ttsUpstreamTimeoutMs ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsUpstreamTimeoutMs', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('ttsUpstreamTimeoutMs', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -509,11 +498,10 @@ export function AdminFeaturesPanel() {
|
||||||
<label className="text-xs font-medium text-foreground">Audio cache size (bytes)</label>
|
<label className="text-xs font-medium text-foreground">Audio cache size (bytes)</label>
|
||||||
{renderSource('ttsCacheMaxSizeBytes')}
|
{renderSource('ttsCacheMaxSizeBytes')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsCacheMaxSizeBytes ?? '')}
|
value={String(draft.ttsCacheMaxSizeBytes ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsCacheMaxSizeBytes', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('ttsCacheMaxSizeBytes', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -523,11 +511,10 @@ export function AdminFeaturesPanel() {
|
||||||
<label className="text-xs font-medium text-foreground">Audio cache TTL (ms)</label>
|
<label className="text-xs font-medium text-foreground">Audio cache TTL (ms)</label>
|
||||||
{renderSource('ttsCacheTtlMs')}
|
{renderSource('ttsCacheTtlMs')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsCacheTtlMs ?? '')}
|
value={String(draft.ttsCacheTtlMs ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsCacheTtlMs', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('ttsCacheTtlMs', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Menu, MenuButton, Transition } from '@headlessui/react';
|
|
||||||
import { Fragment } from 'react';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
|
import { DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
|
||||||
|
|
@ -13,13 +11,16 @@ import {
|
||||||
Field,
|
Field,
|
||||||
Section,
|
Section,
|
||||||
ToggleRow,
|
ToggleRow,
|
||||||
inputClass,
|
|
||||||
Select,
|
Select,
|
||||||
Button,
|
Button,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
|
Textarea,
|
||||||
MenuItemsSurface,
|
MenuItemsSurface,
|
||||||
MenuActionItem,
|
MenuActionItem,
|
||||||
|
MenuRoot,
|
||||||
|
MenuTrigger,
|
||||||
|
MenuTransition,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
|
|
||||||
type ProviderType = TtsProviderId;
|
type ProviderType = TtsProviderId;
|
||||||
|
|
@ -394,7 +395,6 @@ export function AdminProvidersPanel() {
|
||||||
value={form.slug}
|
value={form.slug}
|
||||||
onChange={(e) => setForm({ ...form, slug: e.target.value })}
|
onChange={(e) => setForm({ ...form, slug: e.target.value })}
|
||||||
placeholder="kokoro-prod"
|
placeholder="kokoro-prod"
|
||||||
className={inputClass}
|
|
||||||
disabled={isEditingExisting}
|
disabled={isEditingExisting}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -404,7 +404,6 @@ export function AdminProvidersPanel() {
|
||||||
value={form.displayName}
|
value={form.displayName}
|
||||||
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
|
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
|
||||||
placeholder="Kokoro (production)"
|
placeholder="Kokoro (production)"
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Provider type">
|
<Field label="Provider type">
|
||||||
|
|
@ -483,7 +482,6 @@ export function AdminProvidersPanel() {
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="Enter custom model id"
|
placeholder="Enter custom model id"
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -494,11 +492,11 @@ export function AdminProvidersPanel() {
|
||||||
className="sm:col-span-2"
|
className="sm:col-span-2"
|
||||||
hint="Optional. Applied by default when this shared provider is selected."
|
hint="Optional. Applied by default when this shared provider is selected."
|
||||||
>
|
>
|
||||||
<textarea
|
<Textarea
|
||||||
value={form.defaultInstructions}
|
value={form.defaultInstructions}
|
||||||
onChange={(e) => setForm({ ...form, defaultInstructions: e.target.value })}
|
onChange={(e) => setForm({ ...form, defaultInstructions: e.target.value })}
|
||||||
placeholder="Enter instructions for this model"
|
placeholder="Enter instructions for this model"
|
||||||
className={`${inputClass} min-h-24 resize-y`}
|
className="min-h-24 resize-y"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
@ -509,7 +507,6 @@ export function AdminProvidersPanel() {
|
||||||
value={form.baseUrl}
|
value={form.baseUrl}
|
||||||
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
|
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
|
||||||
placeholder={baseUrlPlaceholder}
|
placeholder={baseUrlPlaceholder}
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
@ -523,7 +520,6 @@ export function AdminProvidersPanel() {
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
|
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
|
||||||
placeholder={isEditingExisting ? `keep existing (${editingProvider?.apiKeyMask ?? ''})` : 'Optional'}
|
placeholder={isEditingExisting ? `keep existing (${editingProvider?.apiKeyMask ?? ''})` : 'Optional'}
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -584,8 +580,8 @@ export function AdminProvidersPanel() {
|
||||||
{p.baseUrl ? p.baseUrl : 'provider base URL default'} · key {p.apiKeyMask}
|
{p.baseUrl ? p.baseUrl : 'provider base URL default'} · key {p.apiKeyMask}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Menu as="div" className="relative shrink-0">
|
<MenuRoot as="div" className="relative shrink-0">
|
||||||
<MenuButton
|
<MenuTrigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
tone="surface"
|
tone="surface"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -594,16 +590,8 @@ export function AdminProvidersPanel() {
|
||||||
disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending}
|
disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending}
|
||||||
>
|
>
|
||||||
<DotsHorizontalIcon className="h-3 w-4" />
|
<DotsHorizontalIcon className="h-3 w-4" />
|
||||||
</MenuButton>
|
</MenuTrigger>
|
||||||
<Transition
|
<MenuTransition>
|
||||||
as={Fragment}
|
|
||||||
enter="transition ease-out duration-100"
|
|
||||||
enterFrom="transform opacity-0 scale-95"
|
|
||||||
enterTo="transform opacity-100 scale-100"
|
|
||||||
leave="transition ease-in duration-75"
|
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
|
||||||
leaveTo="transform opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<MenuItemsSurface
|
<MenuItemsSurface
|
||||||
anchor="bottom end"
|
anchor="bottom end"
|
||||||
className="z-50 mt-2 min-w-[170px] bg-base focus:outline-none"
|
className="z-50 mt-2 min-w-[170px] bg-base focus:outline-none"
|
||||||
|
|
@ -624,8 +612,8 @@ export function AdminProvidersPanel() {
|
||||||
Delete
|
Delete
|
||||||
</MenuActionItem>
|
</MenuActionItem>
|
||||||
</MenuItemsSurface>
|
</MenuItemsSurface>
|
||||||
</Transition>
|
</MenuTransition>
|
||||||
</Menu>
|
</MenuRoot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Menu, MenuButton, Transition } from '@headlessui/react';
|
import { useRef, type CSSProperties, type ReactNode } from 'react';
|
||||||
import { Fragment, useRef, type CSSProperties, type ReactNode } from 'react';
|
|
||||||
import { useDrop } from 'react-dnd';
|
import { useDrop } from 'react-dnd';
|
||||||
import type { Folder, SidebarFilter } from '@/types/documents';
|
import type { Folder, SidebarFilter } from '@/types/documents';
|
||||||
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
|
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
|
||||||
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
|
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
|
||||||
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
||||||
import { IconButton, MenuActionItem, MenuItemsSurface, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui';
|
import { IconButton, MenuActionItem, MenuItemsSurface, MenuRoot, MenuTransition, MenuTrigger, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui';
|
||||||
|
|
||||||
interface FinderSidebarProps {
|
interface FinderSidebarProps {
|
||||||
filter: SidebarFilter;
|
filter: SidebarFilter;
|
||||||
|
|
@ -201,8 +200,8 @@ export function FinderSidebar({
|
||||||
|
|
||||||
<SidebarNavGroup
|
<SidebarNavGroup
|
||||||
action={(
|
action={(
|
||||||
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
|
<MenuRoot as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
|
||||||
<MenuButton
|
<MenuTrigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
size="xs"
|
size="xs"
|
||||||
className="h-3.5 w-5"
|
className="h-3.5 w-5"
|
||||||
|
|
@ -210,16 +209,8 @@ export function FinderSidebar({
|
||||||
aria-label="Folder actions"
|
aria-label="Folder actions"
|
||||||
>
|
>
|
||||||
<DotsHorizontalIcon className="w-4 h-2.5" />
|
<DotsHorizontalIcon className="w-4 h-2.5" />
|
||||||
</MenuButton>
|
</MenuTrigger>
|
||||||
<Transition
|
<MenuTransition>
|
||||||
as={Fragment}
|
|
||||||
enter="transition ease-standard duration-fast"
|
|
||||||
enterFrom="transform opacity-0 scale-95"
|
|
||||||
enterTo="transform opacity-100 scale-100"
|
|
||||||
leave="transition ease-standard duration-fast"
|
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
|
||||||
leaveTo="transform opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<MenuItemsSurface
|
<MenuItemsSurface
|
||||||
anchor="bottom start"
|
anchor="bottom start"
|
||||||
className="z-50 mt-2 min-w-[180px] focus:outline-none normal-case tracking-normal font-normal"
|
className="z-50 mt-2 min-w-[180px] focus:outline-none normal-case tracking-normal font-normal"
|
||||||
|
|
@ -246,8 +237,8 @@ export function FinderSidebar({
|
||||||
Remove All Folders
|
Remove All Folders
|
||||||
</MenuActionItem>
|
</MenuActionItem>
|
||||||
</MenuItemsSurface>
|
</MenuItemsSurface>
|
||||||
</Transition>
|
</MenuTransition>
|
||||||
</Menu>
|
</MenuRoot>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Folders
|
Folders
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Menu, MenuButton, Transition } from '@headlessui/react';
|
|
||||||
import { Fragment } from 'react';
|
|
||||||
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons';
|
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons';
|
||||||
import { ZoomControl } from '@/components/documents/ZoomControl';
|
import { ZoomControl } from '@/components/documents/ZoomControl';
|
||||||
import { UserMenu } from '@/components/auth/UserMenu';
|
import { UserMenu } from '@/components/auth/UserMenu';
|
||||||
import { IconButton, MenuActionItem, MenuItemsSurface, ToolbarButton } from '@/components/ui';
|
import { IconButton, MenuActionItem, MenuItemsSurface, MenuRoot, MenuTransition, MenuTrigger, ToolbarButton } from '@/components/ui';
|
||||||
|
|
||||||
interface DocumentHeaderMenuProps {
|
interface DocumentHeaderMenuProps {
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
|
|
@ -82,24 +80,16 @@ export function DocumentHeaderMenu({
|
||||||
// --- Mobile View ---
|
// --- Mobile View ---
|
||||||
const MobileView = (
|
const MobileView = (
|
||||||
<div className="sm:hidden flex items-center">
|
<div className="sm:hidden flex items-center">
|
||||||
<Menu as="div" className="relative inline-block text-left">
|
<MenuRoot as="div" className="relative inline-block text-left">
|
||||||
<MenuButton
|
<MenuTrigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
tone="surface"
|
tone="surface"
|
||||||
size="sm"
|
size="sm"
|
||||||
title="Menu"
|
title="Menu"
|
||||||
>
|
>
|
||||||
<DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-base ease-standard hover:text-accent" />
|
<DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-base ease-standard hover:text-accent" />
|
||||||
</MenuButton>
|
</MenuTrigger>
|
||||||
<Transition
|
<MenuTransition>
|
||||||
as={Fragment}
|
|
||||||
enter="transition ease-standard duration-fast"
|
|
||||||
enterFrom="transform opacity-0 scale-95"
|
|
||||||
enterTo="transform opacity-100 scale-100"
|
|
||||||
leave="transition ease-standard duration-fast"
|
|
||||||
leaveFrom="transform opacity-100 scale-100"
|
|
||||||
leaveTo="transform opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<MenuItemsSurface className="absolute right-0 z-50 mt-2 min-w-max origin-top-right divide-y divide-line-soft focus:outline-none">
|
<MenuItemsSurface className="absolute right-0 z-50 mt-2 min-w-max origin-top-right divide-y divide-line-soft focus:outline-none">
|
||||||
{/* Zoom Controls Section */}
|
{/* Zoom Controls Section */}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
|
|
@ -140,8 +130,8 @@ export function DocumentHeaderMenu({
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</MenuItemsSurface>
|
</MenuItemsSurface>
|
||||||
</Transition>
|
</MenuTransition>
|
||||||
</Menu>
|
</MenuRoot>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { BaseDocument } from '@/types/documents';
|
import { BaseDocument } from '@/types/documents';
|
||||||
import { Button, ModalFrame, ModalTitle } from '@/components/ui';
|
import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
|
||||||
|
|
||||||
interface DocumentSelectionModalProps {
|
interface DocumentSelectionModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -119,9 +119,7 @@ export function DocumentSelectionModal({
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="flex items-center text-sm font-normal">
|
<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">
|
<label className="flex items-center gap-2 cursor-pointer select-none text-soft hover:text-foreground transition-colors">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
className="rounded border-muted text-accent focus:ring-accent-line"
|
|
||||||
checked={allSelected}
|
checked={allSelected}
|
||||||
ref={input => {
|
ref={input => {
|
||||||
if (input) input.indeterminate = isIndeterminate;
|
if (input) input.indeterminate = isIndeterminate;
|
||||||
|
|
@ -154,11 +152,9 @@ export function DocumentSelectionModal({
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={(e) => handleCheckboxChange(file.id, e.target.checked)}
|
onChange={(e) => handleCheckboxChange(file.id, e.target.checked)}
|
||||||
className="rounded border-muted text-accent focus:ring-accent-line"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Popover } from '@headlessui/react';
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { IconButton, Input, PopoverSurface, PopoverTrigger } from '@/components/ui';
|
import { IconButton, Input, PopoverRoot, PopoverSurface, PopoverTrigger } from '@/components/ui';
|
||||||
|
|
||||||
export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
|
|
@ -66,7 +65,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{/* Page number popup */}
|
{/* Page number popup */}
|
||||||
<Popover className="relative mb-1">
|
<PopoverRoot className="relative mb-1">
|
||||||
<PopoverTrigger className="rounded-full bg-surface-sunken px-2 py-0.5 text-xs" onClick={handlePopoverOpen}>
|
<PopoverTrigger className="rounded-full bg-surface-sunken px-2 py-0.5 text-xs" onClick={handlePopoverOpen}>
|
||||||
<p className="text-xs whitespace-nowrap">
|
<p className="text-xs whitespace-nowrap">
|
||||||
{currentPage} / {numPages || 1}
|
{currentPage} / {numPages || 1}
|
||||||
|
|
@ -92,7 +91,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
<div className="text-xs text-soft text-center">of {numPages || 1}</div>
|
<div className="text-xs text-soft text-center">of {numPages || 1}</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</Popover>
|
</PopoverRoot>
|
||||||
|
|
||||||
{/* Page forward */}
|
{/* Page forward */}
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Popover } from '@headlessui/react';
|
|
||||||
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
import { PopoverSurface, PopoverTrigger, RangeInput } from '@/components/ui';
|
import { PopoverRoot, PopoverSurface, PopoverTrigger, RangeInput } from '@/components/ui';
|
||||||
|
|
||||||
export const SpeedControl = ({
|
export const SpeedControl = ({
|
||||||
setSpeedAndRestart,
|
setSpeedAndRestart,
|
||||||
|
|
@ -88,7 +87,7 @@ export const SpeedControl = ({
|
||||||
const step = 0.1;
|
const step = 0.1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="relative">
|
<PopoverRoot className="relative">
|
||||||
<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">
|
<PopoverTrigger className="space-x-0.5 px-1.5 py-0.5 text-xs sm:space-x-1 sm:px-2 sm:py-1 sm:text-sm">
|
||||||
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
<span className="sm:hidden">{compactTriggerLabel}</span>
|
<span className="sm:hidden">{compactTriggerLabel}</span>
|
||||||
|
|
@ -148,6 +147,6 @@ export const SpeedControl = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</Popover>
|
</PopoverRoot>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Popover, PopoverButton, Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { Book } from 'epubjs';
|
import type { Book } from 'epubjs';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
|
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
|
||||||
import { Button, IconButton, PopoverSurface } from '@/components/ui';
|
import { Button, IconButton, PopoverIconTrigger, PopoverRoot, PopoverSurface } from '@/components/ui';
|
||||||
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||||
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
|
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
|
||||||
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
|
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
|
||||||
|
|
@ -888,15 +888,14 @@ function SegmentsListSkeletonRows() {
|
||||||
|
|
||||||
function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
||||||
return (
|
return (
|
||||||
<Popover className="relative shrink-0">
|
<PopoverRoot className="relative shrink-0">
|
||||||
<PopoverButton
|
<PopoverIconTrigger
|
||||||
as={IconButton}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="Segment metadata"
|
aria-label="Segment metadata"
|
||||||
title="Metadata"
|
title="Metadata"
|
||||||
>
|
>
|
||||||
<InfoIcon className="w-3.5 h-3.5" />
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
</PopoverButton>
|
</PopoverIconTrigger>
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="transition ease-standard duration-fast"
|
enter="transition ease-standard duration-fast"
|
||||||
|
|
@ -963,7 +962,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
||||||
</dl>
|
</dl>
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Popover>
|
</PopoverRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { focusRing, motionColors } from './tokens';
|
||||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
|
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
|
||||||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
|
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
|
||||||
|
|
||||||
export const buttonStyles = variants({
|
const buttonStyles = variants({
|
||||||
base: cn(
|
base: cn(
|
||||||
'inline-flex items-center justify-center font-medium disabled:cursor-not-allowed disabled:opacity-50',
|
'inline-flex items-center justify-center font-medium disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
focusRing,
|
focusRing,
|
||||||
|
|
@ -35,17 +35,6 @@ export const buttonStyles = variants({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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({
|
function buttonClass({
|
||||||
variant = 'secondary',
|
variant = 'secondary',
|
||||||
size = 'md',
|
size = 'md',
|
||||||
|
|
@ -112,3 +101,22 @@ export function ButtonAnchor({
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function InlineButton({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
type = 'button',
|
||||||
|
...props
|
||||||
|
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
className={cn('underline hover:text-foreground', focusRing, motionColors, className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
16
src/components/ui/checkbox.tsx
Normal file
16
src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||||
|
import { cn } from './cn';
|
||||||
|
import { focusRing, motionColors } from './tokens';
|
||||||
|
|
||||||
|
export const checkboxClass = cn(
|
||||||
|
'h-4 w-4 rounded border-line bg-surface text-accent disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
focusRing,
|
||||||
|
motionColors,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Checkbox = forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>
|
||||||
|
>(function Checkbox({ className, ...props }, ref) {
|
||||||
|
return <input ref={ref} type="checkbox" className={cn(checkboxClass, className)} {...props} />;
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export * from './badge';
|
export * from './badge';
|
||||||
export * from './button';
|
export * from './button';
|
||||||
|
export * from './checkbox';
|
||||||
export * from './choice-tile';
|
export * from './choice-tile';
|
||||||
export * from './cn';
|
export * from './cn';
|
||||||
export * from './dialog';
|
export * from './dialog';
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { motionColors } from './tokens';
|
||||||
|
|
||||||
export type InputControlSize = 'sm' | 'md' | 'lg';
|
export type InputControlSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
export const inputStyles = variants({
|
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),
|
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: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
|
|
@ -19,8 +19,6 @@ export const inputStyles = variants({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const inputClass = inputStyles();
|
|
||||||
|
|
||||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }>(function Input({
|
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }>(function Input({
|
||||||
className,
|
className,
|
||||||
controlSize = 'md',
|
controlSize = 'md',
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,33 @@
|
||||||
import { MenuItem, MenuItems } from '@headlessui/react';
|
import {
|
||||||
|
Menu as HeadlessMenu,
|
||||||
|
MenuButton as HeadlessMenuButton,
|
||||||
|
MenuItem,
|
||||||
|
MenuItems,
|
||||||
|
Transition,
|
||||||
|
} from '@headlessui/react';
|
||||||
|
import { Fragment } from 'react';
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import type { HTMLAttributes, ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { cn } from './cn';
|
import { cn } from './cn';
|
||||||
|
|
||||||
const menuPanelClass = 'rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft';
|
const menuPanelClass = 'rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft';
|
||||||
|
|
||||||
export function Menu({
|
export const MenuRoot = HeadlessMenu;
|
||||||
children,
|
export const MenuTrigger = HeadlessMenuButton;
|
||||||
className,
|
|
||||||
...props
|
export function MenuTransition({ children }: { children: ReactNode }) {
|
||||||
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(menuPanelClass, className)} {...props}>
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-standard duration-fast"
|
||||||
|
enterFrom="transform opacity-0 scale-95"
|
||||||
|
enterTo="transform opacity-100 scale-100"
|
||||||
|
leave="transition ease-standard duration-fast"
|
||||||
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
|
leaveTo="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Transition>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import { PopoverButton, PopoverPanel } from '@headlessui/react';
|
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from './cn';
|
import { cn } from './cn';
|
||||||
|
import { IconButton } from './icon-button';
|
||||||
|
|
||||||
const popoverPanelClass = cn(
|
const popoverPanelClass = cn(
|
||||||
'z-50 rounded-md border border-line bg-surface p-3 shadow-elev-2 focus:outline-none',
|
'z-50 rounded-md border border-line bg-surface p-3 shadow-elev-2 focus:outline-none',
|
||||||
);
|
);
|
||||||
|
|
||||||
export const popoverTriggerClass = cn(
|
export const PopoverRoot = Popover;
|
||||||
|
|
||||||
|
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',
|
'inline-flex items-center rounded-md text-foreground hover:bg-accent-wash hover:text-accent focus:outline-none transition-colors duration-fast ease-standard',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -22,6 +25,17 @@ export function PopoverTrigger({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PopoverIconTrigger({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<typeof IconButton>) {
|
||||||
|
return (
|
||||||
|
<PopoverButton as={IconButton} {...props}>
|
||||||
|
{children}
|
||||||
|
</PopoverButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PopoverSurface({
|
export function PopoverSurface({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
|
|
||||||
43
tests/unit/shared-form-control-consumers.vitest.spec.ts
Normal file
43
tests/unit/shared-form-control-consumers.vitest.spec.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
const STANDARD_FORM_CONSUMERS = [
|
||||||
|
'src/app/(app)/signin/page.tsx',
|
||||||
|
'src/app/(app)/signup/page.tsx',
|
||||||
|
'src/components/PrivacyModal.tsx',
|
||||||
|
'src/components/SettingsModal.tsx',
|
||||||
|
'src/components/admin/AdminFeaturesPanel.tsx',
|
||||||
|
'src/components/admin/AdminProvidersPanel.tsx',
|
||||||
|
'src/components/documents/DocumentSelectionModal.tsx',
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('shared form-control consumers', () => {
|
||||||
|
test('standard forms use shared inputs, textareas, and checkboxes', () => {
|
||||||
|
for (const relativePath of STANDARD_FORM_CONSUMERS) {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), relativePath), 'utf8');
|
||||||
|
expect(source, relativePath).not.toMatch(/<(input|textarea)\b/);
|
||||||
|
expect(source, relativePath).not.toContain('inputClass');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auth pages use the shared inline button', () => {
|
||||||
|
for (const relativePath of [
|
||||||
|
'src/app/(app)/signin/page.tsx',
|
||||||
|
'src/app/(app)/signup/page.tsx',
|
||||||
|
]) {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), relativePath), 'utf8');
|
||||||
|
expect(source, relativePath).toContain('<InlineButton');
|
||||||
|
expect(source, relativePath).not.toMatch(/<button\b/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shared primitives own standard form-control chrome', () => {
|
||||||
|
const inputSource = readFileSync(resolve(process.cwd(), 'src/components/ui/input.tsx'), 'utf8');
|
||||||
|
const checkboxSource = readFileSync(resolve(process.cwd(), 'src/components/ui/checkbox.tsx'), 'utf8');
|
||||||
|
expect(inputSource).toContain('inputStyles');
|
||||||
|
expect(inputSource).toContain('export function Textarea');
|
||||||
|
expect(checkboxSource).toContain('checkboxClass');
|
||||||
|
expect(checkboxSource).toContain('export const Checkbox');
|
||||||
|
});
|
||||||
|
});
|
||||||
31
tests/unit/shared-overlay-consumers.vitest.spec.ts
Normal file
31
tests/unit/shared-overlay-consumers.vitest.spec.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
const STANDARD_OVERLAY_CONSUMERS = [
|
||||||
|
'src/components/admin/AdminProvidersPanel.tsx',
|
||||||
|
'src/components/AudiobookExportModal.tsx',
|
||||||
|
'src/components/ColorPicker.tsx',
|
||||||
|
'src/components/doclist/window/FinderSidebar.tsx',
|
||||||
|
'src/components/documents/DocumentHeaderMenu.tsx',
|
||||||
|
'src/components/player/Navigator.tsx',
|
||||||
|
'src/components/player/SpeedControl.tsx',
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('shared overlay consumers', () => {
|
||||||
|
test('standard menus and popovers do not compose Headless UI directly', () => {
|
||||||
|
for (const relativePath of STANDARD_OVERLAY_CONSUMERS) {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), relativePath), 'utf8');
|
||||||
|
expect(source, relativePath).not.toContain("from '@headlessui/react'");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shared overlay primitives own standard composition', () => {
|
||||||
|
const menuSource = readFileSync(resolve(process.cwd(), 'src/components/ui/menu.tsx'), 'utf8');
|
||||||
|
const popoverSource = readFileSync(resolve(process.cwd(), 'src/components/ui/popover.tsx'), 'utf8');
|
||||||
|
expect(menuSource).toContain('export const MenuRoot');
|
||||||
|
expect(menuSource).toContain('export function MenuTransition');
|
||||||
|
expect(popoverSource).toContain('export const PopoverRoot');
|
||||||
|
expect(popoverSource).toContain('export function PopoverIconTrigger');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue