refactor(ui): centralize standard select controls
This commit is contained in:
parent
112dbc0da9
commit
8071ae1a61
7 changed files with 235 additions and 325 deletions
|
|
@ -1,11 +1,11 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { Transition, Listbox, Menu, MenuButton } from '@headlessui/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';
|
||||||
import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIcon, RefreshIcon, DotsVerticalIcon } from '@/components/icons/Icons';
|
import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, RefreshIcon, DotsVerticalIcon } from '@/components/icons/Icons';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
|
|
@ -14,7 +14,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, SharedListboxButton, SharedListboxOption, SharedListboxOptions } from '@/components/ui';
|
import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, Select } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
getAudiobookStatus,
|
getAudiobookStatus,
|
||||||
deleteAudiobookChapter,
|
deleteAudiobookChapter,
|
||||||
|
|
@ -565,51 +565,25 @@ export function AudiobookExportModal({
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Format</label>
|
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Format</label>
|
||||||
{chapters.length === 0 ? (
|
{chapters.length === 0 ? (
|
||||||
<Listbox
|
<Select
|
||||||
value={format}
|
value={format}
|
||||||
onChange={(newFormat) => setFormat(newFormat)}
|
onChange={(newFormat) => setFormat(newFormat)}
|
||||||
|
options={['m4b', 'mp3'] as const}
|
||||||
disabled={chapters.length > 0 || settingsLocked}
|
disabled={chapters.length > 0 || settingsLocked}
|
||||||
>
|
renderValue={(option) => (
|
||||||
<div className="relative">
|
<span className="text-sm font-medium">{option.toUpperCase()}</span>
|
||||||
<SharedListboxButton className="bg-surface">
|
)}
|
||||||
<span className="block truncate text-sm font-medium">{format.toUpperCase()}</span>
|
renderOption={(option, { selected }) => (
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
<ChevronUpDownIcon className="h-4 w-4 text-soft" />
|
{option.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</SharedListboxButton>
|
)}
|
||||||
<Transition
|
buttonClassName="bg-surface"
|
||||||
as={Fragment}
|
chevronClassName="h-4 w-4 text-soft"
|
||||||
leave="transition ease-standard duration-fast"
|
optionInset="none"
|
||||||
leaveFrom="opacity-100"
|
optionItemClassName="py-2"
|
||||||
leaveTo="opacity-0"
|
showCheckmark={false}
|
||||||
>
|
/>
|
||||||
<SharedListboxOptions className="absolute left-0 mt-1 w-full">
|
|
||||||
<SharedListboxOption
|
|
||||||
value="m4b"
|
|
||||||
inset="none"
|
|
||||||
itemClassName="py-2"
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
|
||||||
M4B
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</SharedListboxOption>
|
|
||||||
<SharedListboxOption
|
|
||||||
value="mp3"
|
|
||||||
inset="none"
|
|
||||||
itemClassName="py-2"
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
|
||||||
MP3
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</SharedListboxOption>
|
|
||||||
</SharedListboxOptions>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Listbox>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm font-medium text-foreground py-1.5 pl-3">{format.toUpperCase()}</div>
|
<div className="text-sm font-medium text-foreground py-1.5 pl-3">{format.toUpperCase()}</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react';
|
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
|
||||||
Transition,
|
|
||||||
Listbox,
|
|
||||||
} from '@headlessui/react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { ChevronUpDownIcon, CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon, ChevronRightIcon } from '@/components/icons/Icons';
|
import { CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon, ChevronRightIcon } from '@/components/icons/Icons';
|
||||||
import { useDocuments } from '@/contexts/DocumentContext';
|
import { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
|
|
@ -52,10 +48,8 @@ import {
|
||||||
Input,
|
Input,
|
||||||
ModalFrame,
|
ModalFrame,
|
||||||
ModalTitle,
|
ModalTitle,
|
||||||
|
Select,
|
||||||
inputClass,
|
inputClass,
|
||||||
SharedListboxButton,
|
|
||||||
SharedListboxOption,
|
|
||||||
SharedListboxOptions,
|
|
||||||
} 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';
|
||||||
|
|
@ -654,8 +648,16 @@ export function SettingsModal({
|
||||||
User API keys are restricted and no shared provider is configured. Ask an admin to add one.
|
User API keys are restricted and no shared provider is configured. Ask an admin to add one.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<Listbox
|
<Select
|
||||||
value={selectedProviderOption!}
|
value={selectedProviderOption!}
|
||||||
|
options={ttsProviders}
|
||||||
|
getOptionKey={(provider) => provider.id}
|
||||||
|
renderValue={(provider) => provider.name}
|
||||||
|
renderOption={(provider, { selected }) => (
|
||||||
|
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
|
{provider.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
onChange={(provider) => {
|
onChange={(provider) => {
|
||||||
const defaults = resolveProviderDefaults({
|
const defaults = resolveProviderDefaults({
|
||||||
providerRef: provider.id,
|
providerRef: provider.id,
|
||||||
|
|
@ -675,46 +677,7 @@ export function SettingsModal({
|
||||||
}
|
}
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<SharedListboxButton>
|
|
||||||
<span className="block truncate">
|
|
||||||
{selectedProviderOption?.name || 'Select Provider'}
|
|
||||||
</span>
|
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
||||||
<ChevronUpDownIcon className="h-5 w-5 text-soft" />
|
|
||||||
</span>
|
|
||||||
</SharedListboxButton>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
leave="transition ease-standard duration-fast"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<SharedListboxOptions
|
|
||||||
anchor="bottom start"
|
|
||||||
>
|
|
||||||
{ttsProviders.map((provider) => (
|
|
||||||
<SharedListboxOption
|
|
||||||
key={provider.id}
|
|
||||||
value={provider}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
|
||||||
{provider.name}
|
|
||||||
</span>
|
|
||||||
{selected && (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
|
||||||
<CheckIcon className="h-5 w-5" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SharedListboxOption>
|
|
||||||
))}
|
|
||||||
</SharedListboxOptions>
|
|
||||||
</Transition>
|
|
||||||
</Listbox>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{restrictUserApiKeys && (
|
{restrictUserApiKeys && (
|
||||||
|
|
@ -768,8 +731,30 @@ export function SettingsModal({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Listbox
|
<Select
|
||||||
value={ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]}
|
value={selectedModel}
|
||||||
|
options={ttsModels}
|
||||||
|
getOptionKey={(model) => model.id}
|
||||||
|
renderValue={(model) => (
|
||||||
|
<span className="block">
|
||||||
|
<span className="block truncate">{model.name}</span>
|
||||||
|
{selectedModelVersion ? (
|
||||||
|
<span className="block truncate text-xs text-soft">
|
||||||
|
{selectedModelVersion}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
renderOption={(model, { selected }) => (
|
||||||
|
<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-soft">
|
||||||
|
{model.id.slice(model.id.indexOf(':'))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
onChange={(model) => {
|
onChange={(model) => {
|
||||||
if (model.id === 'custom') {
|
if (model.id === 'custom') {
|
||||||
setModelValue(customModelInput);
|
setModelValue(customModelInput);
|
||||||
|
|
@ -778,62 +763,7 @@ export function SettingsModal({
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<SharedListboxButton>
|
|
||||||
{selectedModel ? (
|
|
||||||
<span className="block">
|
|
||||||
<span className="block truncate">
|
|
||||||
{selectedModel.name}
|
|
||||||
</span>
|
|
||||||
{selectedModelVersion && (
|
|
||||||
<span className="block truncate text-xs text-soft">
|
|
||||||
{selectedModelVersion}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<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-soft" />
|
|
||||||
</span>
|
|
||||||
</SharedListboxButton>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
leave="transition ease-standard duration-fast"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<SharedListboxOptions
|
|
||||||
anchor="bottom start"
|
|
||||||
>
|
|
||||||
{ttsModels.map((model) => (
|
|
||||||
<SharedListboxOption
|
|
||||||
key={model.id}
|
|
||||||
value={model}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<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-soft">
|
|
||||||
{model.id.slice(model.id.indexOf(':'))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{selected && (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
|
||||||
<CheckIcon className="h-5 w-5" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SharedListboxOption>
|
|
||||||
))}
|
|
||||||
</SharedListboxOptions>
|
|
||||||
</Transition>
|
|
||||||
</Listbox>
|
|
||||||
|
|
||||||
{supportsCustom && selectedModelId === 'custom' && (
|
{supportsCustom && selectedModelId === 'custom' && (
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,14 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Listbox, Transition } from '@headlessui/react';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Section,
|
Section,
|
||||||
ToggleRow,
|
ToggleRow,
|
||||||
inputClass,
|
inputClass,
|
||||||
SharedListboxButton,
|
Select,
|
||||||
SharedListboxOption,
|
|
||||||
SharedListboxOptions,
|
|
||||||
Button,
|
Button,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||||
|
|
@ -204,42 +200,19 @@ export function AdminFeaturesPanel() {
|
||||||
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
|
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
|
||||||
</div>
|
</div>
|
||||||
{providerOptions.length > 0 ? (
|
{providerOptions.length > 0 ? (
|
||||||
<Listbox value={selectedProviderOption} onChange={handleProviderChange}>
|
<Select
|
||||||
<SharedListboxButton>
|
value={selectedProviderOption}
|
||||||
<span className="block truncate">{selectedProviderOption?.name ?? 'Select provider'}</span>
|
onChange={handleProviderChange}
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
options={providerOptions}
|
||||||
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
|
getOptionKey={(option) => option.id}
|
||||||
|
renderValue={(option) => option.name}
|
||||||
|
renderOption={(option, { selected }) => (
|
||||||
|
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
|
{option.name}
|
||||||
</span>
|
</span>
|
||||||
</SharedListboxButton>
|
)}
|
||||||
<Transition
|
chevronClassName="h-4 w-4 text-muted"
|
||||||
as={Fragment}
|
/>
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<SharedListboxOptions anchor="bottom start">
|
|
||||||
{providerOptions.map((opt) => (
|
|
||||||
<SharedListboxOption
|
|
||||||
key={opt.id}
|
|
||||||
value={opt}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
|
||||||
{opt.name}
|
|
||||||
</span>
|
|
||||||
{selected && (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
|
||||||
<CheckIcon className="h-5 w-5" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SharedListboxOption>
|
|
||||||
))}
|
|
||||||
</SharedListboxOptions>
|
|
||||||
</Transition>
|
|
||||||
</Listbox>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="px-0.5 py-2 text-sm text-muted">
|
<div className="px-0.5 py-2 text-sm text-muted">
|
||||||
No shared providers yet. Add one first.
|
No shared providers yet. Add one first.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Listbox, Menu, MenuButton, Transition } from '@headlessui/react';
|
import { Menu, MenuButton, Transition } from '@headlessui/react';
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { ChevronUpDownIcon, CheckIcon, DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
|
import { DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
|
||||||
import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||||
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,9 +14,7 @@ import {
|
||||||
Section,
|
Section,
|
||||||
ToggleRow,
|
ToggleRow,
|
||||||
inputClass,
|
inputClass,
|
||||||
SharedListboxButton,
|
Select,
|
||||||
SharedListboxOption,
|
|
||||||
SharedListboxOptions,
|
|
||||||
Button,
|
Button,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
|
|
@ -410,8 +408,17 @@ export function AdminProvidersPanel() {
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Provider type">
|
<Field label="Provider type">
|
||||||
<Listbox
|
<Select
|
||||||
value={selectedProviderType}
|
value={selectedProviderType}
|
||||||
|
options={PROVIDER_TYPE_OPTIONS}
|
||||||
|
getOptionKey={(option) => option.value}
|
||||||
|
renderValue={(option) => option.label}
|
||||||
|
renderOption={(option, { selected }) => (
|
||||||
|
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
chevronClassName="h-4 w-4 text-muted"
|
||||||
onChange={(opt) => {
|
onChange={(opt) => {
|
||||||
const nextModel = providerDefaultModel(opt.value);
|
const nextModel = providerDefaultModel(opt.value);
|
||||||
setForm({
|
setForm({
|
||||||
|
|
@ -423,49 +430,29 @@ export function AdminProvidersPanel() {
|
||||||
});
|
});
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
</SharedListboxButton>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<SharedListboxOptions anchor="bottom start">
|
|
||||||
{PROVIDER_TYPE_OPTIONS.map((opt) => (
|
|
||||||
<SharedListboxOption
|
|
||||||
key={opt.value}
|
|
||||||
value={opt}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
|
||||||
{opt.label}
|
|
||||||
</span>
|
|
||||||
{selected && (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
|
||||||
<CheckIcon className="h-5 w-5" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SharedListboxOption>
|
|
||||||
))}
|
|
||||||
</SharedListboxOptions>
|
|
||||||
</Transition>
|
|
||||||
</Listbox>
|
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Default model" hint="Pre-selected for users picking this provider.">
|
<Field label="Default model" hint="Pre-selected for users picking this provider.">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Listbox
|
<Select
|
||||||
value={selectedModelId}
|
value={selectedModelDefinition}
|
||||||
onChange={(modelId: string) => {
|
options={modelDefinitions}
|
||||||
if (modelId === 'custom') {
|
getOptionKey={(model) => model.id}
|
||||||
|
renderValue={(model) => model.name}
|
||||||
|
renderOption={(model, { selected }) => (
|
||||||
|
<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">
|
||||||
|
{model.id.slice(model.id.indexOf(':'))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
placeholder="Select model"
|
||||||
|
chevronClassName="h-4 w-4 text-muted"
|
||||||
|
onChange={(model) => {
|
||||||
|
if (model.id === 'custom') {
|
||||||
const nextModel = customModelInput.trim();
|
const nextModel = customModelInput.trim();
|
||||||
setForm({
|
setForm({
|
||||||
...form,
|
...form,
|
||||||
|
|
@ -476,54 +463,12 @@ export function AdminProvidersPanel() {
|
||||||
}
|
}
|
||||||
setForm({
|
setForm({
|
||||||
...form,
|
...form,
|
||||||
defaultModel: modelId,
|
defaultModel: model.id,
|
||||||
defaultInstructions: modelSupportsInstructions(modelId) ? form.defaultInstructions : '',
|
defaultInstructions: modelSupportsInstructions(model.id) ? form.defaultInstructions : '',
|
||||||
});
|
});
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
</SharedListboxButton>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<SharedListboxOptions anchor="bottom start">
|
|
||||||
{modelDefinitions.map((model) => (
|
|
||||||
<SharedListboxOption
|
|
||||||
key={model.id}
|
|
||||||
value={model.id}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<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">
|
|
||||||
{model.id.slice(model.id.indexOf(':'))}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{selected && (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
|
||||||
<CheckIcon className="h-5 w-5" />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SharedListboxOption>
|
|
||||||
))}
|
|
||||||
</SharedListboxOptions>
|
|
||||||
</Transition>
|
|
||||||
</Listbox>
|
|
||||||
{supportsCustomModel && selectedModelId === 'custom' && (
|
{supportsCustomModel && selectedModelId === 'custom' && (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,15 @@ import {
|
||||||
clampSegmentPreloadSentenceLookahead,
|
clampSegmentPreloadSentenceLookahead,
|
||||||
clampTtsSegmentMaxBlockLength,
|
clampTtsSegmentMaxBlockLength,
|
||||||
} from '@/types/config';
|
} from '@/types/config';
|
||||||
import { IconButton, RangeInput, Section, ToggleRow, CheckItem, SegmentedControl } from '@/components/ui';
|
import {
|
||||||
|
IconButton,
|
||||||
|
RangeInput,
|
||||||
|
Section,
|
||||||
|
ToggleRow,
|
||||||
|
CheckItem,
|
||||||
|
SegmentedControl,
|
||||||
|
Select,
|
||||||
|
} from '@/components/ui';
|
||||||
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
|
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
|
||||||
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
|
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
|
||||||
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
|
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
|
||||||
|
|
@ -148,6 +156,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, dete
|
||||||
voice,
|
voice,
|
||||||
documentLanguage: resolvedLanguage,
|
documentLanguage: resolvedLanguage,
|
||||||
});
|
});
|
||||||
|
const selectedLanguage = DOCUMENT_LANGUAGE_OPTIONS.find((option) => option.value === language)
|
||||||
|
?? DOCUMENT_LANGUAGE_OPTIONS[0];
|
||||||
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
|
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
|
||||||
const isPdfMode = !epub && !html && !!pdf;
|
const isPdfMode = !epub && !html && !!pdf;
|
||||||
const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages);
|
const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages);
|
||||||
|
|
@ -183,22 +193,16 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, dete
|
||||||
subtitle="Controls sentence splitting and synchronized word alignment."
|
subtitle="Controls sentence splitting and synchronized word alignment."
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
<label className="block space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted">
|
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||||
Document language
|
Document language
|
||||||
</span>
|
</span>
|
||||||
<select
|
<Select
|
||||||
value={language}
|
value={selectedLanguage}
|
||||||
onChange={(event) => onLanguageChange(event.target.value)}
|
onChange={(option) => onLanguageChange(option.value)}
|
||||||
className="w-full rounded-md border border-offbase bg-surface-solid px-3 py-2 text-sm text-foreground"
|
options={DOCUMENT_LANGUAGE_OPTIONS}
|
||||||
>
|
/>
|
||||||
{DOCUMENT_LANGUAGE_OPTIONS.map((option) => (
|
</div>
|
||||||
<option key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{language === 'auto' && detectedLanguage ? (
|
{language === 'auto' && detectedLanguage ? (
|
||||||
<p className="text-xs text-soft">
|
<p className="text-xs text-soft">
|
||||||
Detected from document metadata: {getLanguageDisplayName(detectedLanguage)}
|
Detected from document metadata: {getLanguageDisplayName(detectedLanguage)}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
|
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
|
||||||
import type { ComponentProps } from 'react';
|
import { Fragment, type ComponentProps, type Key, type ReactNode } from 'react';
|
||||||
import { cn } from './cn';
|
import { cn } from './cn';
|
||||||
import { CheckIcon, ChevronRightIcon } from '@/components/icons/Icons';
|
import { CheckIcon, ChevronUpDownIcon } from '@/components/icons/Icons';
|
||||||
|
|
||||||
const listboxButtonClass =
|
const listboxButtonClass =
|
||||||
'relative w-full cursor-pointer rounded-md bg-surface-sunken border border-line py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent-line hover:bg-accent-wash transition-colors duration-fast ease-standard';
|
'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';
|
||||||
|
|
@ -104,39 +104,92 @@ export function SharedListboxOption({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Select({
|
export type SelectOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Select<T = SelectOption>({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
|
getOptionKey,
|
||||||
|
renderValue,
|
||||||
|
renderOption,
|
||||||
|
placeholder = 'Select',
|
||||||
|
disabled,
|
||||||
|
buttonClassName,
|
||||||
|
optionsClassName,
|
||||||
|
optionInset = 'check',
|
||||||
|
optionItemClassName,
|
||||||
|
showCheckmark = true,
|
||||||
|
chevronClassName = 'h-5 w-5 text-soft',
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: T | undefined;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: T) => void;
|
||||||
options: Array<{ value: string; label: string }>;
|
options: readonly T[];
|
||||||
|
getOptionKey?: (option: T) => Key;
|
||||||
|
renderValue?: (option: T) => ReactNode;
|
||||||
|
renderOption?: (option: T, state: { selected: boolean }) => ReactNode;
|
||||||
|
placeholder?: ReactNode;
|
||||||
|
disabled?: boolean;
|
||||||
|
buttonClassName?: string;
|
||||||
|
optionsClassName?: string;
|
||||||
|
optionInset?: 'check' | 'none';
|
||||||
|
optionItemClassName?: string;
|
||||||
|
showCheckmark?: boolean;
|
||||||
|
chevronClassName?: string;
|
||||||
}) {
|
}) {
|
||||||
const activeOption = options.find((option) => option.value === value) ?? options[0];
|
const defaultKey = (option: T): Key => {
|
||||||
|
if (typeof option === 'string' || typeof option === 'number') return option;
|
||||||
|
const record = option as Record<string, unknown>;
|
||||||
|
return String(record.value ?? record.id ?? record.label);
|
||||||
|
};
|
||||||
|
const defaultRender = (option: T): ReactNode => {
|
||||||
|
if (typeof option === 'string' || typeof option === 'number') return String(option);
|
||||||
|
const record = option as Record<string, unknown>;
|
||||||
|
return String(record.label ?? record.name ?? record.value ?? record.id ?? '');
|
||||||
|
};
|
||||||
|
const optionKey = getOptionKey ?? defaultKey;
|
||||||
|
const valueRenderer = renderValue ?? defaultRender;
|
||||||
|
const optionRenderer = renderOption ?? ((option: T) => valueRenderer(option));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Listbox value={value} onChange={onChange}>
|
<Listbox value={value} onChange={onChange} disabled={disabled}>
|
||||||
<SharedListboxButton>
|
<SharedListboxButton className={buttonClassName}>
|
||||||
<span>{activeOption?.label ?? 'Select'}</span>
|
<span className="block truncate">{value === undefined ? placeholder : valueRenderer(value)}</span>
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-soft">
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
<ChevronRightIcon className="h-4 w-4 rotate-90" aria-hidden="true" />
|
<ChevronUpDownIcon className={chevronClassName} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
</SharedListboxButton>
|
</SharedListboxButton>
|
||||||
<SharedListboxOptions anchor="bottom">
|
<Transition
|
||||||
{options.map((option) => (
|
as={Fragment}
|
||||||
<SharedListboxOption key={option.value} value={option.value}>
|
leave="transition ease-standard duration-fast"
|
||||||
{({ selected }) => (
|
leaveFrom="opacity-100"
|
||||||
<>
|
leaveTo="opacity-0"
|
||||||
<span className="absolute left-2 flex items-center text-accent">
|
>
|
||||||
{selected ? <CheckIcon className="h-4 w-4" aria-hidden="true" /> : null}
|
<SharedListboxOptions anchor="bottom start" className={optionsClassName}>
|
||||||
</span>
|
{options.map((option) => (
|
||||||
<span>{option.label}</span>
|
<SharedListboxOption
|
||||||
</>
|
key={optionKey(option)}
|
||||||
)}
|
value={option}
|
||||||
</SharedListboxOption>
|
inset={optionInset}
|
||||||
))}
|
itemClassName={optionItemClassName}
|
||||||
</SharedListboxOptions>
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
{optionRenderer(option, { selected })}
|
||||||
|
{showCheckmark && selected ? (
|
||||||
|
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
|
||||||
|
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SharedListboxOption>
|
||||||
|
))}
|
||||||
|
</SharedListboxOptions>
|
||||||
|
</Transition>
|
||||||
</Listbox>
|
</Listbox>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
tests/unit/shared-select-consumers.vitest.spec.ts
Normal file
31
tests/unit/shared-select-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_SELECT_CONSUMERS = [
|
||||||
|
'src/components/SettingsModal.tsx',
|
||||||
|
'src/components/documents/DocumentSettings.tsx',
|
||||||
|
'src/components/admin/AdminFeaturesPanel.tsx',
|
||||||
|
'src/components/admin/AdminProvidersPanel.tsx',
|
||||||
|
'src/components/AudiobookExportModal.tsx',
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('shared Select consumers', () => {
|
||||||
|
test('standard settings dropdowns use the high-level UI primitive', () => {
|
||||||
|
for (const relativePath of STANDARD_SELECT_CONSUMERS) {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), relativePath), 'utf8');
|
||||||
|
expect(source, relativePath).toContain('<Select');
|
||||||
|
expect(source, relativePath).not.toContain('<Listbox');
|
||||||
|
expect(source, relativePath).not.toContain('<SharedListboxButton');
|
||||||
|
expect(source, relativePath).not.toContain('<SharedListboxOptions');
|
||||||
|
expect(source, relativePath).not.toContain('<SharedListboxOption');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the shared Select owns standard dropdown chrome', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/components/ui/select.tsx'), 'utf8');
|
||||||
|
expect(source).toContain('ChevronUpDownIcon');
|
||||||
|
expect(source).toContain('CheckIcon');
|
||||||
|
expect(source).toContain('<Transition');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue