feat(ui): enhance AudiobookExportModal and ConfirmDialog with improved layout and styling; update VoicesControlBase for better dropdown handling
This commit is contained in:
parent
3c37b0c59e
commit
b6852da2a2
4 changed files with 250 additions and 168 deletions
|
|
@ -491,161 +491,183 @@ export function AudiobookExportModal({
|
||||||
<h3 className="text-lg font-medium text-foreground">Export Audiobook</h3>
|
<h3 className="text-lg font-medium text-foreground">Export Audiobook</h3>
|
||||||
</div>
|
</div>
|
||||||
{!isGenerating && (
|
{!isGenerating && (
|
||||||
<div className="flex justify-center">
|
<div className="w-full rounded-xl border border-offbase bg-background">
|
||||||
<div className="w-full rounded-xl border border-offbase bg-background p-4">
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-offbase bg-base rounded-t-xl">
|
||||||
<div>
|
<h4 className="text-sm font-medium text-foreground tracking-tight">Generation settings</h4>
|
||||||
<h4 className="text-sm font-medium text-foreground">Generation settings</h4>
|
{settingsLocked && (
|
||||||
<p className="text-xs text-muted mt-0.5">
|
<span className="inline-flex items-center gap-1 rounded-md bg-offbase px-2 py-0.5 text-[11px] font-medium text-muted uppercase tracking-wider">
|
||||||
These settings are saved per audiobook for consistent resumes across devices.
|
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="currentColor"><path fillRule="evenodd" d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7A1.5 1.5 0 0 0 3 8.5v5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 11.5 7V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z" clipRule="evenodd" /></svg>
|
||||||
</p>
|
Locked
|
||||||
</div>
|
</span>
|
||||||
{settingsLocked && (
|
)}
|
||||||
<span className="inline-flex items-center rounded-full bg-offbase px-2 py-0.5 text-xs text-muted">
|
</div>
|
||||||
Locked
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLegacyAudiobookMissingSettings && (
|
<div className="p-4">
|
||||||
<div className="mt-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-xs text-foreground">
|
{isLegacyAudiobookMissingSettings && (
|
||||||
<div className="font-medium">Saved generation settings not found</div>
|
<div className="mb-4 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-xs text-foreground">
|
||||||
<div className="mt-1 text-muted">
|
<div className="font-medium">Saved generation settings not found</div>
|
||||||
This audiobook was likely created before v1 metadata was introduced, so OpenReader can’t know
|
<div className="mt-1 text-muted">
|
||||||
which voice/speeds/format were used. Consider resetting this audiobook to regenerate it with
|
This audiobook was likely created before v1 metadata was introduced, so OpenReader can't know
|
||||||
v1 metadata (so settings are saved for resumes across devices).
|
which voice/speeds/format were used. Consider resetting this audiobook to regenerate it with
|
||||||
</div>
|
v1 metadata (so settings are saved for resumes across devices).
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{settingsLocked && savedSettings ? (
|
|
||||||
<div className="mt-3 text-sm">
|
|
||||||
<div className="text-muted">
|
|
||||||
Voice: <span className="text-foreground">{savedSettings.voice}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
Native speed: <span className="text-foreground">{formatSpeed(savedSettings.nativeSpeed)}x</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
Post speed: <span className="text-foreground">{formatSpeed(savedSettings.postSpeed)}x</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
Format: <span className="text-foreground">{savedSettings.format.toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted mt-2">
|
|
||||||
Reset the audiobook to change generation settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-3 space-y-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs font-medium text-foreground">Voice</div>
|
|
||||||
<VoicesControlBase
|
|
||||||
availableVoices={availableVoices}
|
|
||||||
voice={audiobookVoice}
|
|
||||||
onChangeVoice={setAudiobookVoice}
|
|
||||||
ttsProvider={ttsProvider}
|
|
||||||
ttsModel={ttsModel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs font-medium text-foreground">Output format</div>
|
|
||||||
{chapters.length === 0 ? (
|
|
||||||
<Listbox
|
|
||||||
value={format}
|
|
||||||
onChange={(newFormat) => setFormat(newFormat)}
|
|
||||||
disabled={chapters.length > 0 || settingsLocked}
|
|
||||||
>
|
|
||||||
<div className="relative inline-block">
|
|
||||||
<ListboxButton className="relative cursor-pointer rounded-lg bg-base py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent min-w-[120px]">
|
|
||||||
<span className="block truncate text-sm font-medium">{format.toUpperCase()}</span>
|
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
||||||
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
|
||||||
</span>
|
|
||||||
</ListboxButton>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<ListboxOptions className="absolute left-0 mt-1 max-h-60 w-full overflow-auto rounded-md bg-base py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10">
|
|
||||||
<ListboxOption
|
|
||||||
value="m4b"
|
|
||||||
className={({ active }) =>
|
|
||||||
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
|
||||||
M4B
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ListboxOption>
|
|
||||||
<ListboxOption
|
|
||||||
value="mp3"
|
|
||||||
className={({ active }) =>
|
|
||||||
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
|
||||||
MP3
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ListboxOption>
|
|
||||||
</ListboxOptions>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Listbox>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-foreground">{format.toUpperCase()}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="text-xs font-medium text-foreground mr-1">Native model speed</div>
|
|
||||||
<div className="text-xs text-muted">• {formatSpeed(nativeSpeed)}x</div>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0.5"
|
|
||||||
max="3"
|
|
||||||
step="0.1"
|
|
||||||
value={nativeSpeed}
|
|
||||||
onChange={(e) => setNativeSpeed(parseFloat(e.target.value))}
|
|
||||||
className="w-full max-w-xs bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="text-xs font-medium text-foreground mr-1">Post-generation speed</div>
|
|
||||||
<div className="text-xs text-muted">• {formatSpeed(postSpeed)}x</div>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0.5"
|
|
||||||
max="3"
|
|
||||||
step="0.1"
|
|
||||||
value={postSpeed}
|
|
||||||
onChange={(e) => setPostSpeed(parseFloat(e.target.value))}
|
|
||||||
className="w-full max-w-xs bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4 space-y-2">
|
{settingsLocked && savedSettings ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg bg-base p-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Voice</div>
|
||||||
|
<div className="text-sm font-medium text-foreground truncate">{savedSettings.voice}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-base p-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Format</div>
|
||||||
|
<div className="text-sm font-medium text-foreground">{savedSettings.format.toUpperCase()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg bg-base p-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Native speed</div>
|
||||||
|
<div className="text-sm font-medium text-foreground">{formatSpeed(savedSettings.nativeSpeed)}x</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-base p-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-muted mb-1">Post speed</div>
|
||||||
|
<div className="text-sm font-medium text-foreground">{formatSpeed(savedSettings.postSpeed)}x</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Reset the audiobook to change generation settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Voice & Format row */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Voice</label>
|
||||||
|
<VoicesControlBase
|
||||||
|
availableVoices={availableVoices}
|
||||||
|
voice={audiobookVoice}
|
||||||
|
onChangeVoice={setAudiobookVoice}
|
||||||
|
ttsProvider={ttsProvider}
|
||||||
|
ttsModel={ttsModel}
|
||||||
|
dropdownDirection="down"
|
||||||
|
variant="field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Format</label>
|
||||||
|
{chapters.length === 0 ? (
|
||||||
|
<Listbox
|
||||||
|
value={format}
|
||||||
|
onChange={(newFormat) => setFormat(newFormat)}
|
||||||
|
disabled={chapters.length > 0 || settingsLocked}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<ListboxButton className="relative cursor-pointer rounded-lg bg-base py-1.5 pl-3 pr-10 text-left text-foreground focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent w-full">
|
||||||
|
<span className="block truncate text-sm font-medium">{format.toUpperCase()}</span>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
|
||||||
|
</span>
|
||||||
|
</ListboxButton>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<ListboxOptions className="absolute left-0 mt-1 max-h-60 w-full overflow-auto rounded-lg bg-base py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10">
|
||||||
|
<ListboxOption
|
||||||
|
value="m4b"
|
||||||
|
className={({ active }) =>
|
||||||
|
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
|
M4B
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ListboxOption>
|
||||||
|
<ListboxOption
|
||||||
|
value="mp3"
|
||||||
|
className={({ active }) =>
|
||||||
|
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
|
MP3
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ListboxOption>
|
||||||
|
</ListboxOptions>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm font-medium text-foreground py-1.5 pl-3">{format.toUpperCase()}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Speed controls */}
|
||||||
|
<div className="rounded-lg bg-base p-3 space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Native model speed</label>
|
||||||
|
<span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(nativeSpeed)}x</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0.5"
|
||||||
|
max="3"
|
||||||
|
step="0.1"
|
||||||
|
value={nativeSpeed}
|
||||||
|
onChange={(e) => setNativeSpeed(parseFloat(e.target.value))}
|
||||||
|
className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent [&::-moz-range-thumb]:border-0"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-muted">
|
||||||
|
<span>0.5x</span>
|
||||||
|
<span>3x</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-offbase" />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-[11px] uppercase tracking-wider font-medium text-muted">Post-generation speed</label>
|
||||||
|
<span className="text-xs font-medium text-accent tabular-nums">{formatSpeed(postSpeed)}x</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0.5"
|
||||||
|
max="3"
|
||||||
|
step="0.1"
|
||||||
|
value={postSpeed}
|
||||||
|
onChange={(e) => setPostSpeed(parseFloat(e.target.value))}
|
||||||
|
className="w-full h-1.5 bg-offbase rounded-full appearance-none cursor-pointer [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:shadow-sm [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-1.5 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent [&::-moz-range-thumb]:border-0"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-[10px] text-muted">
|
||||||
|
<span>0.5x</span>
|
||||||
|
<span>3x</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
{chapters.length === 0 && (
|
{chapters.length === 0 && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleStartGeneration}
|
onClick={handleStartGeneration}
|
||||||
disabled={!canGenerate}
|
disabled={!canGenerate}
|
||||||
className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm
|
className="flex-1 inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm
|
||||||
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||||
|
|
@ -657,7 +679,7 @@ export function AudiobookExportModal({
|
||||||
<Button
|
<Button
|
||||||
onClick={handleStartGeneration}
|
onClick={handleStartGeneration}
|
||||||
disabled={!canGenerate}
|
disabled={!canGenerate}
|
||||||
className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm
|
className="flex-1 inline-flex justify-center rounded-lg bg-accent px-3 py-2 text-sm
|
||||||
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.01] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100"
|
||||||
|
|
@ -669,10 +691,10 @@ export function AudiobookExportModal({
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowResetConfirm(true)}
|
onClick={() => setShowResetConfirm(true)}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
className="w-full justify-center rounded-lg bg-red-500 px-3 py-2 text-sm
|
className="inline-flex justify-center rounded-lg border border-red-500 bg-transparent px-3 py-2 text-sm
|
||||||
font-medium text-background hover:bg-red-500/90 focus:outline-none
|
font-medium text-red-500 hover:bg-red-500 hover:text-background focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.01]"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.01]"
|
||||||
title="Delete all generated chapters/pages for this document"
|
title="Delete all generated chapters/pages for this document"
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
|
|
@ -716,7 +738,7 @@ export function AudiobookExportModal({
|
||||||
{chapters.length > 0 && (
|
{chapters.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`space-y-2 max-h-96 overflow-scroll pr-1 ${isRefreshingChapters ? 'opacity-70 transition-opacity' : ''}`}
|
className={`w-full space-y-2 max-h-96 overflow-y-auto ${isRefreshingChapters ? 'opacity-70 transition-opacity' : ''}`}
|
||||||
aria-busy={isRefreshingChapters}
|
aria-busy={isRefreshingChapters}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export function ConfirmDialog({
|
||||||
{title}
|
{title}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-sm text-foreground/90">{message}</p>
|
<p className="text-sm text-foreground/90 break-words">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end space-x-3">
|
<div className="mt-6 flex justify-end space-x-3">
|
||||||
|
|
@ -85,7 +85,7 @@ export function ConfirmDialog({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`inline-flex justify-center rounded-lg px-3 py-1.5 text-sm
|
className={`inline-flex justify-center rounded-lg px-3 py-1.5 text-sm text-wrap
|
||||||
font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
|
font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]
|
||||||
${isDangerous
|
${isDangerous
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export function ProgressCard({
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium text-foreground hover:text-accent hover:bg-background/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors"
|
className="shrink-0 inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium text-foreground hover:text-accent hover:bg-background/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
onClick={(e) => onCancel(e)}
|
onClick={(e) => onCancel(e)}
|
||||||
>
|
>
|
||||||
<span>{cancelText}</span>
|
<span>{cancelText}</span>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
ListboxOption,
|
ListboxOption,
|
||||||
ListboxOptions,
|
ListboxOptions,
|
||||||
} from '@headlessui/react';
|
} from '@headlessui/react';
|
||||||
import { ChevronUpDownIcon, AudioWaveIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { buildKokoroVoiceString, getMaxVoicesForProvider, isKokoroModel, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
|
import { buildKokoroVoiceString, getMaxVoicesForProvider, isKokoroModel, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
|
||||||
|
|
||||||
|
|
@ -16,13 +16,36 @@ export function VoicesControlBase({
|
||||||
onChangeVoice,
|
onChangeVoice,
|
||||||
ttsProvider,
|
ttsProvider,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
|
dropdownDirection = 'up',
|
||||||
|
variant = 'compact',
|
||||||
}: {
|
}: {
|
||||||
availableVoices: string[];
|
availableVoices: string[];
|
||||||
voice: string;
|
voice: string;
|
||||||
onChangeVoice: (voice: string) => void;
|
onChangeVoice: (voice: string) => void;
|
||||||
ttsProvider: string;
|
ttsProvider: string;
|
||||||
ttsModel: string;
|
ttsModel: string;
|
||||||
|
dropdownDirection?: 'up' | 'down';
|
||||||
|
variant?: 'compact' | 'field';
|
||||||
}) {
|
}) {
|
||||||
|
const dropdownPosition = dropdownDirection === 'down'
|
||||||
|
? 'top-full left-0 mt-1'
|
||||||
|
: 'bottom-full right-0 mb-1';
|
||||||
|
|
||||||
|
const buttonClass = variant === 'field'
|
||||||
|
? 'relative cursor-pointer rounded-lg bg-base py-1.5 pl-3 pr-10 text-left text-foreground focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.01] hover:text-accent w-full'
|
||||||
|
: 'flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent';
|
||||||
|
|
||||||
|
const iconClass = variant === 'field'
|
||||||
|
? 'h-3.5 w-3.5 shrink-0'
|
||||||
|
: 'h-3 w-3 sm:h-3.5 sm:w-3.5';
|
||||||
|
|
||||||
|
const chevronClass = variant === 'field'
|
||||||
|
? 'h-4 w-4 text-muted'
|
||||||
|
: 'h-2.5 w-2.5 sm:h-3 sm:w-3';
|
||||||
|
|
||||||
|
const dropdownWidth = variant === 'field'
|
||||||
|
? 'w-full'
|
||||||
|
: isKokoroModel(ttsModel) && getMaxVoicesForProvider(ttsProvider, ttsModel) > 1 ? 'w-40 sm:w-44' : 'w-28 sm:w-32';
|
||||||
const isKokoro = isKokoroModel(ttsModel);
|
const isKokoro = isKokoroModel(ttsModel);
|
||||||
const maxVoices = getMaxVoicesForProvider(ttsProvider, ttsModel);
|
const maxVoices = getMaxVoicesForProvider(ttsProvider, ttsModel);
|
||||||
|
|
||||||
|
|
@ -91,33 +114,70 @@ export function VoicesControlBase({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
|
<ListboxButton className={buttonClass}>
|
||||||
<AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
{variant === 'field' ? (
|
||||||
<span>{selectedVoices.length > 1 ? selectedVoices.join(' + ') : selectedVoices[0] || currentVoice}</span>
|
<>
|
||||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
<span className="flex items-center gap-2 truncate text-sm font-medium">
|
||||||
|
<AudioWaveIcon className={iconClass} />
|
||||||
|
{selectedVoices.length > 1 ? selectedVoices.join(' + ') : selectedVoices[0] || currentVoice}
|
||||||
|
</span>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<ChevronUpDownIcon className={chevronClass} />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AudioWaveIcon className={iconClass} />
|
||||||
|
<span>{selectedVoices.length > 1 ? selectedVoices.join(' + ') : selectedVoices[0] || currentVoice}</span>
|
||||||
|
<ChevronUpDownIcon className={chevronClass} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ListboxButton>
|
</ListboxButton>
|
||||||
<ListboxOptions className="absolute bottom-full right-0 mb-1 z-50 w-40 sm:w-44 !h-auto !min-h-0 !max-h-[50vh] overflow-y-auto overscroll-contain rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
<ListboxOptions className={`absolute ${dropdownPosition} z-50 ${dropdownWidth} !h-auto !min-h-0 !max-h-[50vh] overflow-y-auto overscroll-contain rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none`}>
|
||||||
{availableVoices.map((voiceId) => (
|
{availableVoices.map((voiceId) => (
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
key={voiceId}
|
key={voiceId}
|
||||||
value={voiceId}
|
value={voiceId}
|
||||||
className={({ active, selected }) =>
|
className={({ active, selected }) =>
|
||||||
`relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}`
|
`relative cursor-pointer select-none py-1 px-2 sm:py-2 sm:px-3 flex items-center gap-2 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium bg-accent text-background' : ''} ${selected && active ? 'text-foreground' : ''}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="text-xs sm:text-sm">{voiceId}</span>
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
{selected ? (
|
||||||
|
<CheckIcon className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<span className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs sm:text-sm">{voiceId}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ListboxOption>
|
</ListboxOption>
|
||||||
))}
|
))}
|
||||||
</ListboxOptions>
|
</ListboxOptions>
|
||||||
</Listbox>
|
</Listbox>
|
||||||
) : (
|
) : (
|
||||||
<Listbox value={currentVoice} onChange={onChangeVoice}>
|
<Listbox value={currentVoice} onChange={onChangeVoice}>
|
||||||
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
|
<ListboxButton className={buttonClass}>
|
||||||
<AudioWaveIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
{variant === 'field' ? (
|
||||||
<span>{currentVoice}</span>
|
<>
|
||||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
<span className="flex items-center gap-2 truncate text-sm font-medium">
|
||||||
|
<AudioWaveIcon className={iconClass} />
|
||||||
|
{currentVoice}
|
||||||
|
</span>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<ChevronUpDownIcon className={chevronClass} />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AudioWaveIcon className={iconClass} />
|
||||||
|
<span>{currentVoice}</span>
|
||||||
|
<ChevronUpDownIcon className={chevronClass} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</ListboxButton>
|
</ListboxButton>
|
||||||
<ListboxOptions className="absolute bottom-full right-0 mb-1 z-50 w-28 sm:w-32 !h-auto !min-h-0 !max-h-[50vh] overflow-y-auto overscroll-contain rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
<ListboxOptions className={`absolute ${dropdownPosition} z-50 ${dropdownWidth} !h-auto !min-h-0 !max-h-[50vh] overflow-y-auto overscroll-contain rounded-lg bg-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none`}>
|
||||||
{availableVoices.map((voiceId) => (
|
{availableVoices.map((voiceId) => (
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
key={voiceId}
|
key={voiceId}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue