refactor(audiobook): standardize chapter regeneration APIs and UX
- Removed `onProgress` callback from `regenerateChapter` and related functions across `TTS`, `EPUB`, and `PDF` contexts to simplify the chapter regeneration API. - Updated `AudiobookExportModal` to align with the refined regeneration API, including removing granular chapter progress display and adding a hint about TTS caching behavior. - Introduced `TTSAudiobookChapter` interface and renamed `ContinuationMergeResult` to `TTSSmartMergeResult` and `PageTurnEstimate` to `TTSPageTurnEstimate` for better type consistency and clarity. - Applied minor styling adjustments to buttons and listbox components in modals for visual consistency. - Added `'use client'` directive to several client-side components for Next.js 13+ compatibility. - Updated Dockerfile build command from `pnpm run build` to `pnpm build`. - Added Playwright tests to verify backend chapter state after regeneration.
This commit is contained in:
parent
04def62ff5
commit
ff150eec01
16 changed files with 384 additions and 347 deletions
|
|
@ -21,7 +21,7 @@ COPY . .
|
||||||
|
|
||||||
# Build the Next.js application
|
# Build the Next.js application
|
||||||
RUN pnpm exec next telemetry disable
|
RUN pnpm exec next telemetry disable
|
||||||
RUN pnpm run build
|
RUN pnpm build
|
||||||
|
|
||||||
# Expose the port the app runs on
|
# Expose the port the app runs on
|
||||||
EXPOSE 3003
|
EXPOSE 3003
|
||||||
|
|
|
||||||
|
|
@ -94,10 +94,9 @@ export default function EPUBPage() {
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: 'mp3' | 'm4b',
|
||||||
onProgress: (progress: number) => void,
|
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
return regenerateEPUBChapter(chapterIndex, bookId, format, onProgress, signal);
|
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
|
||||||
}, [regenerateEPUBChapter]);
|
}, [regenerateEPUBChapter]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ html.light {
|
||||||
--base: #f7fafc;
|
--base: #f7fafc;
|
||||||
--offbase: #e2e8f0;
|
--offbase: #e2e8f0;
|
||||||
--accent: #ef4444;
|
--accent: #ef4444;
|
||||||
--secondary-accent: #3b82f6;
|
--secondary-accent: #ed6868;
|
||||||
--muted: #718096;
|
--muted: #718096;
|
||||||
--prism-gradient: linear-gradient(90deg,
|
--prism-gradient: linear-gradient(90deg,
|
||||||
#fecaca,
|
#fecaca,
|
||||||
|
|
@ -25,7 +25,7 @@ html.dark {
|
||||||
--base: #171717;
|
--base: #171717;
|
||||||
--offbase: #343434;
|
--offbase: #343434;
|
||||||
--accent: #f87171;
|
--accent: #f87171;
|
||||||
--secondary-accent: #60a5fa;
|
--secondary-accent: #eb6262;
|
||||||
--muted: #a3a3a3;
|
--muted: #a3a3a3;
|
||||||
--prism-gradient: linear-gradient(90deg,
|
--prism-gradient: linear-gradient(90deg,
|
||||||
#fca5a5,
|
#fca5a5,
|
||||||
|
|
|
||||||
|
|
@ -88,10 +88,9 @@ export default function PDFViewerPage() {
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: 'mp3' | 'm4b',
|
||||||
onProgress: (progress: number) => void,
|
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
return regeneratePDFChapter(chapterIndex, bookId, format, onProgress, signal);
|
return regeneratePDFChapter(chapterIndex, bookId, format, signal);
|
||||||
}, [regeneratePDFChapter]);
|
}, [regeneratePDFChapter]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useState, useRef, useCallback, useEffect } from 'react';
|
import { Fragment, useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { Dialog, DialogPanel, Transition, TransitionChild, Button, Listbox, ListboxButton, ListboxOptions, ListboxOption, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react';
|
import { Dialog, DialogPanel, Transition, TransitionChild, Button, Listbox, ListboxButton, ListboxOptions, ListboxOption, Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react';
|
||||||
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
||||||
|
|
@ -7,16 +9,7 @@ import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIco
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { LoadingSpinner } from '@/components/Spinner';
|
import { LoadingSpinner } from '@/components/Spinner';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
|
import type { TTSAudiobookChapter } from '@/types/tts';
|
||||||
interface AudiobookChapter {
|
|
||||||
index: number;
|
|
||||||
title: string;
|
|
||||||
duration?: number;
|
|
||||||
status: 'pending' | 'generating' | 'completed' | 'error';
|
|
||||||
bookId?: string;
|
|
||||||
format?: 'mp3' | 'm4b';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AudiobookExportModalProps {
|
interface AudiobookExportModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
|
@ -25,16 +18,15 @@ interface AudiobookExportModalProps {
|
||||||
onGenerateAudiobook: (
|
onGenerateAudiobook: (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
onChapterComplete: (chapter: AudiobookChapter) => void,
|
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||||
format: 'mp3' | 'm4b'
|
format: 'mp3' | 'm4b'
|
||||||
) => Promise<string>; // Returns bookId
|
) => Promise<string>; // Returns bookId
|
||||||
onRegenerateChapter?: (
|
onRegenerateChapter?: (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: 'mp3' | 'm4b',
|
||||||
onProgress: (progress: number) => void,
|
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
) => Promise<AudiobookChapter>;
|
) => Promise<TTSAudiobookChapter>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AudiobookExportModal({
|
export function AudiobookExportModal({
|
||||||
|
|
@ -48,7 +40,7 @@ export function AudiobookExportModal({
|
||||||
const { isLoading, isDBReady } = useConfig();
|
const { isLoading, isDBReady } = useConfig();
|
||||||
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [chapters, setChapters] = useState<AudiobookChapter[]>([]);
|
const [chapters, setChapters] = useState<TTSAudiobookChapter[]>([]);
|
||||||
const [bookId, setBookId] = useState<string | null>(null);
|
const [bookId, setBookId] = useState<string | null>(null);
|
||||||
const [isCombining, setIsCombining] = useState(false);
|
const [isCombining, setIsCombining] = useState(false);
|
||||||
const [isLoadingExisting, setIsLoadingExisting] = useState(false);
|
const [isLoadingExisting, setIsLoadingExisting] = useState(false);
|
||||||
|
|
@ -56,11 +48,11 @@ export function AudiobookExportModal({
|
||||||
const [currentChapter, setCurrentChapter] = useState<string>('');
|
const [currentChapter, setCurrentChapter] = useState<string>('');
|
||||||
const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b');
|
const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b');
|
||||||
const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null);
|
const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null);
|
||||||
const [regenerationProgress, setRegenerationProgress] = useState<number>(0);
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const [pendingDeleteChapter, setPendingDeleteChapter] = useState<AudiobookChapter | null>(null);
|
const [pendingDeleteChapter, setPendingDeleteChapter] = useState<TTSAudiobookChapter | null>(null);
|
||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [showRegenerateHint, setShowRegenerateHint] = useState(false);
|
||||||
|
|
||||||
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
|
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
|
||||||
if (soft) {
|
if (soft) {
|
||||||
|
|
@ -108,7 +100,7 @@ export function AudiobookExportModal({
|
||||||
}
|
}
|
||||||
}, [isOpen, documentId, isGenerating, fetchExistingChapters]);
|
}, [isOpen, documentId, isGenerating, fetchExistingChapters]);
|
||||||
|
|
||||||
const handleChapterComplete = useCallback((chapter: AudiobookChapter) => {
|
const handleChapterComplete = useCallback((chapter: TTSAudiobookChapter) => {
|
||||||
setChapters(prev => {
|
setChapters(prev => {
|
||||||
const existing = prev.find(c => c.index === chapter.index);
|
const existing = prev.find(c => c.index === chapter.index);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|
@ -183,11 +175,14 @@ export function AudiobookExportModal({
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRegenerateChapter = useCallback(async (chapter: AudiobookChapter) => {
|
const handleRegenerateChapter = useCallback(async (chapter: TTSAudiobookChapter) => {
|
||||||
if (!onRegenerateChapter || !bookId) return;
|
if (!onRegenerateChapter || !bookId) return;
|
||||||
|
|
||||||
|
if (!showRegenerateHint) {
|
||||||
|
setShowRegenerateHint(true);
|
||||||
|
}
|
||||||
|
|
||||||
setRegeneratingChapter(chapter.index);
|
setRegeneratingChapter(chapter.index);
|
||||||
setRegenerationProgress(0);
|
|
||||||
setCurrentChapter(`Regenerating: ${chapter.title}`);
|
setCurrentChapter(`Regenerating: ${chapter.title}`);
|
||||||
abortControllerRef.current = new AbortController();
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
|
@ -210,10 +205,6 @@ export function AudiobookExportModal({
|
||||||
chapter.index,
|
chapter.index,
|
||||||
bookId,
|
bookId,
|
||||||
format,
|
format,
|
||||||
(progress) => {
|
|
||||||
setRegenerationProgress(progress);
|
|
||||||
setProgress(progress);
|
|
||||||
},
|
|
||||||
abortControllerRef.current.signal
|
abortControllerRef.current.signal
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -239,14 +230,13 @@ export function AudiobookExportModal({
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setRegeneratingChapter(null);
|
setRegeneratingChapter(null);
|
||||||
setRegenerationProgress(0);
|
|
||||||
setCurrentChapter('');
|
setCurrentChapter('');
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
abortControllerRef.current = null;
|
abortControllerRef.current = null;
|
||||||
// Refresh chapters to get updated data (soft refresh list only)
|
// Refresh chapters to get updated data (soft refresh list only)
|
||||||
await fetchExistingChapters(true);
|
await fetchExistingChapters(true);
|
||||||
}
|
}
|
||||||
}, [onRegenerateChapter, bookId, format, setProgress, fetchExistingChapters]);
|
}, [onRegenerateChapter, bookId, format, setProgress, fetchExistingChapters, showRegenerateHint]);
|
||||||
|
|
||||||
const performDeleteChapter = useCallback(async () => {
|
const performDeleteChapter = useCallback(async () => {
|
||||||
if (!bookId || !pendingDeleteChapter) return;
|
if (!bookId || !pendingDeleteChapter) return;
|
||||||
|
|
@ -287,7 +277,7 @@ export function AudiobookExportModal({
|
||||||
}
|
}
|
||||||
}, [bookId, documentId, setProgress, fetchExistingChapters]);
|
}, [bookId, documentId, setProgress, fetchExistingChapters]);
|
||||||
|
|
||||||
const handleDownloadChapter = useCallback(async (chapter: AudiobookChapter) => {
|
const handleDownloadChapter = useCallback(async (chapter: TTSAudiobookChapter) => {
|
||||||
if (!chapter.bookId) return;
|
if (!chapter.bookId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -357,24 +347,25 @@ export function AudiobookExportModal({
|
||||||
|
|
||||||
// Compute display list including gaps before the highest existing index
|
// Compute display list including gaps before the highest existing index
|
||||||
const maxIndex = chapters.length > 0 ? Math.max(...chapters.map(c => c.index)) : -1;
|
const maxIndex = chapters.length > 0 ? Math.max(...chapters.map(c => c.index)) : -1;
|
||||||
const displayChapters: AudiobookChapter[] =
|
const displayChapters: TTSAudiobookChapter[] =
|
||||||
maxIndex >= 0
|
maxIndex >= 0
|
||||||
? Array.from({ length: maxIndex + 1 }, (_, i) => {
|
? Array.from({ length: maxIndex + 1 }, (_, i) => {
|
||||||
const existing = chapters.find(c => c.index === i);
|
const existing = chapters.find(c => c.index === i);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
return {
|
return {
|
||||||
index: i,
|
index: i,
|
||||||
title: documentType === 'pdf' ? `Page ${i + 1}` : `Chapter ${i + 1}`,
|
title: documentType === 'pdf' ? `Page ${i + 1}` : `Chapter ${i + 1}`,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
bookId: bookId || undefined,
|
bookId: bookId || undefined,
|
||||||
format
|
format
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Determine if we should show the Resume button
|
// Determine if we should show the Resume and Reset buttons
|
||||||
const hasIncompleteOrMissing = displayChapters.some(c => c.status !== 'completed');
|
const hasAnyChapters = chapters.length > 0;
|
||||||
const showResumeButton = !isGenerating && chapters.length > 0 && (progress < 100 || hasIncompleteOrMissing);
|
const showResumeButton = !isGenerating && !regeneratingChapter && hasAnyChapters;
|
||||||
|
const showResetButton = !isGenerating && !regeneratingChapter && hasAnyChapters;
|
||||||
|
|
||||||
// Do not render until storage/config is initialized
|
// Do not render until storage/config is initialized
|
||||||
if (isLoading || !isDBReady) {
|
if (isLoading || !isDBReady) {
|
||||||
|
|
@ -383,7 +374,7 @@ export function AudiobookExportModal({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProgressPopup
|
<ProgressPopup
|
||||||
isOpen={isGenerating && !isOpen}
|
isOpen={isGenerating && !isOpen}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
||||||
|
|
@ -395,7 +386,7 @@ export function AudiobookExportModal({
|
||||||
totalChapters={documentType === 'epub' ? undefined : undefined}
|
totalChapters={documentType === 'epub' ? undefined : undefined}
|
||||||
completedChapters={chapters.filter(c => c.status === 'completed').length}
|
completedChapters={chapters.filter(c => c.status === 'completed').length}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
|
<Dialog as="div" className="relative z-50" onClose={() => setIsOpen(false)}>
|
||||||
<TransitionChild
|
<TransitionChild
|
||||||
|
|
@ -428,264 +419,296 @@ export function AudiobookExportModal({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center gap-3">
|
<div className="flex justify-between items-center gap-3">
|
||||||
<h3 className="text-lg font-medium text-foreground">Export Audiobook</h3>
|
<h3 className="text-lg font-medium text-foreground">Export Audiobook</h3>
|
||||||
{!isGenerating && (
|
{!isGenerating && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{chapters.length === 0 && (
|
{chapters.length === 0 && (
|
||||||
<Listbox
|
<Listbox
|
||||||
value={format}
|
value={format}
|
||||||
onChange={(newFormat) => setFormat(newFormat)}
|
onChange={(newFormat) => setFormat(newFormat)}
|
||||||
disabled={chapters.length > 0}
|
disabled={chapters.length > 0}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<ListboxButton className="relative cursor-pointer rounded-lg bg-background py-2 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-[100px]">
|
<ListboxButton className="relative cursor-pointer rounded-lg bg-background 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-[100px]">
|
||||||
<span className="block truncate text-sm font-medium">{format.toUpperCase()}</span>
|
<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">
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
||||||
</span>
|
</span>
|
||||||
</ListboxButton>
|
</ListboxButton>
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
leave="transition ease-in duration-100"
|
leave="transition ease-in duration-100"
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<ListboxOptions className="absolute right-0 mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10">
|
<ListboxOptions className="absolute right-0 mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-10">
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
value="m4b"
|
value="m4b"
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${
|
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
|
||||||
active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
}`
|
||||||
}`
|
}
|
||||||
}
|
>
|
||||||
>
|
{({ selected }) => (
|
||||||
{({ selected }) => (
|
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
M4B
|
||||||
M4B
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</ListboxOption>
|
||||||
</ListboxOption>
|
<ListboxOption
|
||||||
<ListboxOption
|
value="mp3"
|
||||||
value="mp3"
|
className={({ active }) =>
|
||||||
className={({ active }) =>
|
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
|
||||||
`relative cursor-pointer select-none py-2 pl-3 pr-4 ${
|
}`
|
||||||
active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
}
|
||||||
}`
|
>
|
||||||
}
|
{({ selected }) => (
|
||||||
>
|
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
{({ selected }) => (
|
MP3
|
||||||
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
</span>
|
||||||
MP3
|
)}
|
||||||
</span>
|
</ListboxOption>
|
||||||
)}
|
</ListboxOptions>
|
||||||
</ListboxOption>
|
</Transition>
|
||||||
</ListboxOptions>
|
</div>
|
||||||
</Transition>
|
</Listbox>
|
||||||
</div>
|
)}
|
||||||
</Listbox>
|
{chapters.length === 0 && (
|
||||||
)}
|
|
||||||
{chapters.length === 0 && (
|
|
||||||
<Button
|
|
||||||
onClick={handleStartGeneration}
|
|
||||||
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
|
||||||
font-medium text-background hover:opacity-95 focus:outline-none
|
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
|
||||||
>
|
|
||||||
Start Generation
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{showResumeButton && (
|
|
||||||
<>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleStartGeneration}
|
onClick={handleStartGeneration}
|
||||||
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||||
font-medium text-background hover:opacity-95 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.04]"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
|
>
|
||||||
|
Start Generation
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showResumeButton && (
|
||||||
|
<Button
|
||||||
|
onClick={handleStartGeneration}
|
||||||
|
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||||
|
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
>
|
>
|
||||||
Resume
|
Resume
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{showResetButton && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowResetConfirm(true)}
|
onClick={() => setShowResetConfirm(true)}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
className="inline-flex justify-center rounded-lg bg-red-500 px-4 py-2 text-sm
|
className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm
|
||||||
font-medium text-background hover:opacity-95 focus:outline-none
|
font-medium text-background hover:bg-red-500/90 focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:bg-red-500 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]"
|
||||||
title="Delete all generated chapters/pages for this document"
|
title="Delete all generated chapters/pages for this document"
|
||||||
>
|
>
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* Progress Info */}
|
|
||||||
{isGenerating && (
|
|
||||||
<ProgressCard
|
|
||||||
progress={progress}
|
|
||||||
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
operationType="audiobook"
|
|
||||||
currentChapter={currentChapter}
|
|
||||||
completedChapters={chapters.filter(c => c.status === 'completed').length}
|
|
||||||
cancelText="Cancel"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{chapters.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className={`space-y-2 max-h-96 overflow-y-auto ${isRefreshingChapters ? 'opacity-70 transition-opacity' : ''}`} aria-busy={isRefreshingChapters}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h4 className="text-sm font-medium text-foreground">Chapters</h4>
|
|
||||||
{isRefreshingChapters && <ClockIcon className="h-4 w-4 text-muted animate-spin" />}
|
|
||||||
</div>
|
|
||||||
{displayChapters.map((chapter) => (
|
|
||||||
<div
|
|
||||||
key={chapter.index}
|
|
||||||
className={`flex items-center justify-between p-2 sm:p-3 rounded-lg bg-offbase ${(regeneratingChapter === chapter.index || chapter.status === 'generating') ? 'prism-outline' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center space-x-3 flex-1">
|
|
||||||
{chapter.status === 'completed' ? (
|
|
||||||
<CheckCircleIcon className="h-5 w-5 text-accent" />
|
|
||||||
) : onRegenerateChapter ? (
|
|
||||||
<Button
|
|
||||||
onClick={() => handleRegenerateChapter(chapter)}
|
|
||||||
disabled={regeneratingChapter !== null || chapter.status === 'generating' || isGenerating}
|
|
||||||
className="inline-flex items-center justify-center rounded-full bg-accent/10 text-accent hover:bg-accent/20 p-1.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04] disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
title={chapter.status === 'generating' ? 'Generating...' : 'Regenerate this chapter'}
|
|
||||||
>
|
|
||||||
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index || chapter.status === 'generating' ? 'animate-spin' : ''}`} />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<ClockIcon className="h-5 w-5 text-muted" />
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-foreground">
|
|
||||||
{chapter.title}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted">
|
|
||||||
{chapter.status !== 'completed' && <span className="text-warning">Missing • </span>}Duration: {formatDuration(chapter.duration)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
|
|
||||||
<Menu as="div" className="relative inline-block text-left">
|
|
||||||
<MenuButton
|
|
||||||
className="inline-flex items-center justify-center rounded-md p-1.5 hover:bg-background focus:outline-none focus-visible:ring-2 focus-visible:ring-accent text-muted hover:text-foreground transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
|
||||||
title="Chapter actions"
|
|
||||||
>
|
|
||||||
<DotsVerticalIcon className="h-5 w-5" />
|
|
||||||
</MenuButton>
|
|
||||||
<Transition
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<MenuItems className="absolute right-0 mt-2 w-44 origin-top-right rounded-md bg-background shadow-lg ring-1 ring-black/5 focus:outline-none z-10 p-1">
|
|
||||||
{onRegenerateChapter && !isGenerating && (
|
|
||||||
<MenuItem disabled={regeneratingChapter !== null}>
|
|
||||||
{({ active, disabled }) => (
|
|
||||||
<button
|
|
||||||
onClick={() => handleRegenerateChapter(chapter)}
|
|
||||||
disabled={disabled}
|
|
||||||
className={`${active ? 'bg-accent/10 text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
||||||
title="Regenerate this chapter"
|
|
||||||
>
|
|
||||||
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
|
|
||||||
<span>{regeneratingChapter === chapter.index ? `Regenerating (${Math.round(regenerationProgress)}%)` : 'Regenerate'}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
{chapter.status === 'completed' && (
|
|
||||||
<>
|
|
||||||
<MenuItem>
|
|
||||||
{({ active }) => (
|
|
||||||
<button
|
|
||||||
onClick={() => handleDownloadChapter(chapter)}
|
|
||||||
className={`${active ? 'bg-accent/10 text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
|
|
||||||
>
|
|
||||||
<DownloadIcon className="h-4 w-4" />
|
|
||||||
<span>Download</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem>
|
|
||||||
{({ active }) => (
|
|
||||||
<button
|
|
||||||
onClick={() => setPendingDeleteChapter(chapter)}
|
|
||||||
className={`${active ? 'bg-accent/10 text-accent' : 'text-accent'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
|
|
||||||
title="Delete this chapter"
|
|
||||||
>
|
|
||||||
<XCircleIcon className="h-4 w-4" />
|
|
||||||
<span>Delete</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</MenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</MenuItems>
|
|
||||||
</Transition>
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{showRegenerateHint && (
|
||||||
{bookId && !isGenerating && (
|
<div className="flex items-start justify-between bg-offbase border border-offbase rounded-md px-3 py-2 text-xs sm:text-sm">
|
||||||
<div className="pt-4 border-t border-offbase">
|
<p className="text-xs sm:text-sm text-foreground">
|
||||||
|
TTS audio for this chapter may be cached
|
||||||
|
<br />
|
||||||
|
Change the TTS playback options or restart the server to force uncached regeneration
|
||||||
|
</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDownloadComplete}
|
onClick={() => setShowRegenerateHint(false)}
|
||||||
disabled={isCombining}
|
className="ml-3 p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
|
||||||
className="w-full inline-flex justify-center items-center space-x-2 rounded-lg bg-accent px-4 py-3 text-sm
|
aria-label="Dismiss regenerate hint"
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
|
|
||||||
font-medium text-background hover:opacity-95 focus:outline-none
|
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
|
||||||
>
|
>
|
||||||
<DownloadIcon className="h-5 w-5" />
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<span>{isCombining ? 'Combining chapters...' : `Full Download (${format.toUpperCase()})`}</span>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
{/* Progress Info */}
|
||||||
)}
|
{isGenerating && (
|
||||||
|
<ProgressCard
|
||||||
|
progress={progress}
|
||||||
|
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
operationType="audiobook"
|
||||||
|
currentChapter={currentChapter}
|
||||||
|
completedChapters={chapters.filter(c => c.status === 'completed').length}
|
||||||
|
cancelText="Cancel"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{chapters.length === 0 && !isGenerating && !isLoadingExisting && (
|
{chapters.length > 0 && (
|
||||||
<div className="text-center py-8">
|
<>
|
||||||
<p className="text-sm text-muted">
|
<div className={`space-y-2 max-h-96 ${isRefreshingChapters ? 'opacity-70 transition-opacity' : ''}`} aria-busy={isRefreshingChapters}>
|
||||||
Click "Start Generation" to begin creating your audiobook.
|
<div className="flex items-center gap-2">
|
||||||
<br />
|
<h4 className="text-sm font-medium text-foreground">Chapters</h4>
|
||||||
Individual chapters will appear here as they are generated.
|
{isRefreshingChapters && <ClockIcon className="h-4 w-4 text-muted animate-spin" />}
|
||||||
</p>
|
</div>
|
||||||
|
{displayChapters.map((chapter) => (
|
||||||
|
<div
|
||||||
|
key={chapter.index}
|
||||||
|
className={`flex items-center justify-between px-2 sm:px-3 py-1 sm:py-1.5 rounded-lg bg-offbase ${(regeneratingChapter === chapter.index || chapter.status === 'generating') ? 'prism-outline' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3 flex-1">
|
||||||
|
{chapter.status === 'completed' ? (
|
||||||
|
<CheckCircleIcon className="h-5 w-5 text-accent" />
|
||||||
|
) : onRegenerateChapter ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRegenerateChapter(chapter)}
|
||||||
|
disabled={regeneratingChapter !== null || chapter.status === 'generating' || isGenerating}
|
||||||
|
className="inline-flex items-center justify-center rounded-full bg-offbase text-accent hover:bg-accent/20 p-1.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title={chapter.status === 'generating' ? 'Generating...' : 'Regenerate this chapter'}
|
||||||
|
>
|
||||||
|
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index || chapter.status === 'generating' ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<ClockIcon className="h-5 w-5 text-muted" />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{chapter.title}
|
||||||
|
</p>
|
||||||
|
<p>•</p>
|
||||||
|
<p className="text-xs text-muted mt-0.5">
|
||||||
|
{chapter.status !== 'completed' && <span className="text-warning">Missing • </span>}{formatDuration(chapter.duration)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
|
||||||
|
<Menu as="div" className="relative inline-block text-left">
|
||||||
|
<MenuButton
|
||||||
|
className="inline-flex items-center justify-center rounded-md p-1.5 hover:bg-background focus:outline-none focus-visible:ring-2 focus-visible:ring-accent text-muted hover:text-foreground transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
|
title="Chapter actions"
|
||||||
|
>
|
||||||
|
<DotsVerticalIcon className="h-5 w-5" />
|
||||||
|
</MenuButton>
|
||||||
|
<Transition
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<MenuItems className="absolute right-0 bottom-full mb-2 w-44 origin-bottom-right rounded-md bg-background shadow-lg ring-1 ring-black/5 focus:outline-none z-10 p-1">
|
||||||
|
{chapter.status === 'completed' && (
|
||||||
|
<>
|
||||||
|
<MenuItem>
|
||||||
|
{({ active }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setPendingDeleteChapter(chapter)}
|
||||||
|
className={`${active ? 'bg-offbase' : ''} text-red-500 group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
|
||||||
|
title="Delete this chapter"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="h-4 w-4" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem>
|
||||||
|
{({ active }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownloadChapter(chapter)}
|
||||||
|
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="h-4 w-4" />
|
||||||
|
<span>Download</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{regeneratingChapter === chapter.index && (
|
||||||
|
<MenuItem>
|
||||||
|
{({ active }) => (
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className={`${active ? 'bg-offbase text-red-500' : 'text-red-500'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm`}
|
||||||
|
title="Cancel this chapter regeneration"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="h-4 w-4" />
|
||||||
|
<span>Cancel</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{onRegenerateChapter && !isGenerating && (
|
||||||
|
<MenuItem disabled={regeneratingChapter !== null}>
|
||||||
|
{({ active, disabled }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRegenerateChapter(chapter)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded px-2 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
|
title="Regenerate this chapter"
|
||||||
|
>
|
||||||
|
<RefreshIcon className={`h-4 w-4 ${regeneratingChapter === chapter.index ? 'animate-spin' : ''}`} />
|
||||||
|
<span>{regeneratingChapter === chapter.index ? 'Regenerating...' : 'Regenerate'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</MenuItems>
|
||||||
|
{/* end of menu items */}
|
||||||
|
</Transition>
|
||||||
|
</Menu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bookId && !isGenerating && (
|
||||||
|
<div className="pt-4 border-t border-offbase">
|
||||||
|
<Button
|
||||||
|
onClick={handleDownloadComplete}
|
||||||
|
disabled={isCombining}
|
||||||
|
className="w-full inline-flex justify-center items-center space-x-2 rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
|
||||||
|
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||||
|
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
||||||
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
||||||
|
>
|
||||||
|
<DownloadIcon className="h-5 w-5" />
|
||||||
|
<span>{isCombining ? 'Combining chapters...' : `Full Download (${format.toUpperCase()})`}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chapters.length === 0 && !isGenerating && !isLoadingExisting && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Click "Start Generation" to begin creating your audiobook.
|
||||||
|
<br />
|
||||||
|
Individual chapters will appear here as they are generated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
<div className="mt-6 flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm
|
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
font-medium text-foreground hover:bg-offbase 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.04] hover:text-accent"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DialogPanel>
|
</DialogPanel>
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
@ -710,7 +733,7 @@ export function AudiobookExportModal({
|
||||||
onClose={() => setShowResetConfirm(false)}
|
onClose={() => setShowResetConfirm(false)}
|
||||||
onConfirm={performResetAll}
|
onConfirm={performResetAll}
|
||||||
title="Reset Audiobook"
|
title="Reset Audiobook"
|
||||||
message="Reset audiobook? This deletes all generated chapters/pages and any combined files. This cannot be undone."
|
message="Reset audiobook? This deletes all generated chapters/pages and any combined files. This cannot be undone."
|
||||||
confirmText="Reset"
|
confirmText="Reset"
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
isDangerous
|
isDangerous
|
||||||
|
|
|
||||||
|
|
@ -75,8 +75,8 @@ export function ConfirmDialog({
|
||||||
<div className="mt-6 flex justify-end space-x-3">
|
<div className="mt-6 flex justify-end space-x-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm
|
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
font-medium text-foreground hover:bg-offbase 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.04] hover:text-accent"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
@ -85,12 +85,12 @@ export function ConfirmDialog({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`inline-flex justify-center rounded-lg px-4 py-2 text-sm
|
className={`inline-flex justify-center rounded-lg px-3 py-1.5 text-sm
|
||||||
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
|
||||||
? 'bg-accent text-background hover:bg-accent/90 focus-visible:ring-accent'
|
? 'bg-accent text-background hover:bg-secondary-accent focus-visible:ring-accent'
|
||||||
: 'bg-accent text-background hover:bg-accent/90 focus-visible:ring-accent'
|
: 'bg-accent text-background hover:bg-secondary-accent focus-visible:ring-accent'
|
||||||
}`}
|
}`}
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { useParams } from 'next/navigation';
|
||||||
|
|
||||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||||
|
|
||||||
const viewTypes = [
|
const viewTypeTextMapping = [
|
||||||
{ id: 'single', name: 'Single Page' },
|
{ id: 'single', name: 'Single Page' },
|
||||||
{ id: 'dual', name: 'Two Pages' },
|
{ id: 'dual', name: 'Two Pages' },
|
||||||
{ id: 'scroll', name: 'Continuous Scroll' },
|
{ id: 'scroll', name: 'Continuous Scroll' },
|
||||||
|
|
@ -45,7 +45,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
right: rightMargin
|
right: rightMargin
|
||||||
});
|
});
|
||||||
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
const [isAudiobookModalOpen, setIsAudiobookModalOpen] = useState(false);
|
||||||
const selectedView = viewTypes.find(v => v.id === viewType) || viewTypes[0];
|
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
|
||||||
|
|
||||||
// Sync local margins with global state
|
// Sync local margins with global state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -91,13 +91,12 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: 'mp3' | 'm4b',
|
||||||
onProgress: (progress: number) => void,
|
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
if (epub) {
|
if (epub) {
|
||||||
return regenerateEPUBChapter(chapterIndex, bookId, format, onProgress, signal);
|
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
|
||||||
} else {
|
} else {
|
||||||
return regeneratePDFChapter(chapterIndex, bookId, format, onProgress, signal);
|
return regeneratePDFChapter(chapterIndex, bookId, format, signal);
|
||||||
}
|
}
|
||||||
}, [epub, regenerateEPUBChapter, regeneratePDFChapter]);
|
}, [epub, regenerateEPUBChapter, regeneratePDFChapter]);
|
||||||
|
|
||||||
|
|
@ -141,10 +140,10 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
{isDev && !html && <div className="space-y-2 mb-4">
|
{isDev && !html && <div className="space-y-2 mb-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||||
font-medium text-background hover:opacity-95 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.04]"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
||||||
onClick={() => setIsAudiobookModalOpen(true)}
|
onClick={() => setIsAudiobookModalOpen(true)}
|
||||||
>
|
>
|
||||||
Export Audiobook
|
Export Audiobook
|
||||||
|
|
@ -248,7 +247,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
>
|
>
|
||||||
<div className="relative z-10 space-y-2">
|
<div className="relative z-10 space-y-2">
|
||||||
<label className="block text-sm font-medium text-foreground">Mode</label>
|
<label className="block text-sm font-medium text-foreground">Mode</label>
|
||||||
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 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">
|
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background 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.009] hover:text-accent hover:bg-offbase">
|
||||||
<span className="block truncate">{selectedView.name}</span>
|
<span className="block truncate">{selectedView.name}</span>
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
||||||
|
|
@ -261,11 +260,11 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<ListboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
|
<ListboxOptions className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
|
||||||
{viewTypes.map((view) => (
|
{viewTypeTextMapping.map((view) => (
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
key={view.id}
|
key={view.id}
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
value={view}
|
value={view}
|
||||||
|
|
@ -365,8 +364,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
<div className="mt-3 flex justify-end">
|
<div className="mt-3 flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex justify-center rounded-lg bg-background px-4 py-2 text-sm
|
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
font-medium text-foreground hover:bg-offbase 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.04] hover:text-accent z-1"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent z-1"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
|
|
|
||||||
|
|
@ -350,7 +350,7 @@ export function SettingsModal() {
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 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">
|
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background 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.009] hover:text-accent hover:bg-offbase">
|
||||||
<span className="block truncate">
|
<span className="block truncate">
|
||||||
{ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'}
|
{ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -369,8 +369,8 @@ export function SettingsModal() {
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
|
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${
|
||||||
active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
active ? 'bg-offbase text-accent' : 'text-foreground'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
value={provider}
|
value={provider}
|
||||||
|
|
@ -405,7 +405,7 @@ export function SettingsModal() {
|
||||||
value={localApiKey}
|
value={localApiKey}
|
||||||
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
||||||
placeholder={!isDev && localTTSProvider === 'deepinfra' ? "Deepinfra free or override apikey" : "Using environment variable"}
|
placeholder={!isDev && localTTSProvider === 'deepinfra' ? "Deepinfra free or override apikey" : "Using environment variable"}
|
||||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -425,7 +425,7 @@ export function SettingsModal() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 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">
|
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background 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.009] hover:text-accent hover:bg-offbase">
|
||||||
<span className="block truncate">
|
<span className="block truncate">
|
||||||
{ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'}
|
{ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -444,8 +444,8 @@ export function SettingsModal() {
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
key={model.id}
|
key={model.id}
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
|
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${
|
||||||
active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
active ? 'bg-offbase text-accent' : 'text-foreground'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
value={model}
|
value={model}
|
||||||
|
|
@ -477,7 +477,7 @@ export function SettingsModal() {
|
||||||
setModelValue(e.target.value);
|
setModelValue(e.target.value);
|
||||||
}}
|
}}
|
||||||
placeholder="Enter custom model name"
|
placeholder="Enter custom model name"
|
||||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -490,7 +490,7 @@ export function SettingsModal() {
|
||||||
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="w-full h-24 rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
className="w-full h-24 rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -507,7 +507,7 @@ 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="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -517,7 +517,7 @@ export function SettingsModal() {
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
font-medium text-foreground hover:bg-offbase 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.04] hover:text-accent"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
|
@ -534,7 +534,7 @@ export function SettingsModal() {
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
||||||
font-medium text-background hover:bg-accent/90 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.04] hover:text-background"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
|
|
@ -559,7 +559,7 @@ export function SettingsModal() {
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">Theme</label>
|
<label className="block text-sm font-medium text-foreground">Theme</label>
|
||||||
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
|
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
|
||||||
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 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">
|
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background 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.009] hover:text-accent hover:bg-offbase">
|
||||||
<span className="block truncate">{selectedTheme.name}</span>
|
<span className="block truncate">{selectedTheme.name}</span>
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
|
||||||
|
|
@ -571,12 +571,12 @@ export function SettingsModal() {
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<ListboxOptions className="absolute mt-1 max-h-40 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
|
<ListboxOptions className="absolute mt-1 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
|
||||||
{themes.map((theme) => (
|
{themes.map((theme) => (
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
key={theme.id}
|
key={theme.id}
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground'
|
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
value={theme}
|
value={theme}
|
||||||
|
|
@ -609,7 +609,7 @@ export function SettingsModal() {
|
||||||
onClick={handleLoad}
|
onClick={handleLoad}
|
||||||
disabled={isSyncing || isLoading}
|
disabled={isSyncing || isLoading}
|
||||||
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
font-medium text-foreground hover:bg-offbase 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.04] hover:text-accent
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||||
disabled:opacity-50"
|
disabled:opacity-50"
|
||||||
|
|
@ -620,7 +620,7 @@ export function SettingsModal() {
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
disabled={isSyncing || isLoading}
|
disabled={isSyncing || isLoading}
|
||||||
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
||||||
font-medium text-foreground hover:bg-background/90 focus:outline-none
|
font-medium text-foreground hover:bg-offbase 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.04] hover:text-accent
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||||
disabled:opacity-50"
|
disabled:opacity-50"
|
||||||
|
|
@ -636,7 +636,7 @@ export function SettingsModal() {
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowClearLocalConfirm(true)}
|
onClick={() => setShowClearLocalConfirm(true)}
|
||||||
className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm
|
className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm
|
||||||
font-medium text-background hover:bg-accent/90 focus:outline-none
|
font-medium text-background hover:bg-red-500/90 focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
|
focus-visible:ring-2 focus-visible:bg-red-500 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]"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
export function ZoomControl({
|
export function ZoomControl({
|
||||||
value,
|
value,
|
||||||
onIncrease,
|
onIncrease,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
import { useState, DragEvent } from 'react';
|
import { useState, DragEvent } from 'react';
|
||||||
import { Button, Transition } from '@headlessui/react';
|
import { Button, Transition } from '@headlessui/react';
|
||||||
import { DocumentListItem } from './DocumentListItem';
|
import { DocumentListItem } from './DocumentListItem';
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ interface EPUBContextType {
|
||||||
clearCurrDoc: () => void;
|
clearCurrDoc: () => void;
|
||||||
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
||||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
|
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
|
||||||
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', onProgress: (progress: number) => void, signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
|
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
|
||||||
bookRef: RefObject<Book | null>;
|
bookRef: RefObject<Book | null>;
|
||||||
renditionRef: RefObject<Rendition | undefined>;
|
renditionRef: RefObject<Rendition | undefined>;
|
||||||
tocRef: RefObject<NavItem[]>;
|
tocRef: RefObject<NavItem[]>;
|
||||||
|
|
@ -527,7 +527,6 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: 'mp3' | 'm4b',
|
||||||
onProgress: (progress: number) => void,
|
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
|
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ interface PDFContextType {
|
||||||
enableHighlight?: boolean
|
enableHighlight?: boolean
|
||||||
) => void;
|
) => void;
|
||||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
|
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
|
||||||
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', onProgress: (progress: number) => void, signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
|
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
|
||||||
isAudioCombining: boolean;
|
isAudioCombining: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -479,7 +479,6 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: 'mp3' | 'm4b',
|
||||||
onProgress: (progress: number) => void,
|
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
|
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,8 @@ import { preprocessSentenceForAudio, processTextToSentences } from '@/lib/nlp';
|
||||||
import { isKokoroModel } from '@/utils/voice';
|
import { isKokoroModel } from '@/utils/voice';
|
||||||
import type {
|
import type {
|
||||||
TTSLocation,
|
TTSLocation,
|
||||||
ContinuationMergeResult,
|
TTSSmartMergeResult,
|
||||||
PageTurnEstimate,
|
TTSPageTurnEstimate,
|
||||||
TTSPlaybackState,
|
TTSPlaybackState,
|
||||||
TTSRequestPayload,
|
TTSRequestPayload,
|
||||||
TTSRequestHeaders,
|
TTSRequestHeaders,
|
||||||
|
|
@ -176,7 +176,7 @@ const stripContinuationPrefix = (text: string, prefix: string) => {
|
||||||
return { text, removed: false };
|
return { text, removed: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractContinuationSlice = (nextText: string): ContinuationMergeResult | null => {
|
const extractContinuationSlice = (nextText: string): TTSSmartMergeResult | null => {
|
||||||
if (!nextText?.trim()) {
|
if (!nextText?.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -216,7 +216,7 @@ const extractContinuationSlice = (nextText: string): ContinuationMergeResult | n
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const mergeContinuation = (text: string, nextText: string): ContinuationMergeResult | null => {
|
const mergeContinuation = (text: string, nextText: string): TTSSmartMergeResult | null => {
|
||||||
if (!needsSentenceContinuation(text)) {
|
if (!needsSentenceContinuation(text)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -329,7 +329,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
// Track continuation slices for PDF/EPUB page transitions
|
// Track continuation slices for PDF/EPUB page transitions
|
||||||
const continuationCarryRef = useRef<Map<string, string>>(new Map());
|
const continuationCarryRef = useRef<Map<string, string>>(new Map());
|
||||||
const epubContinuationRef = useRef<string | null>(null);
|
const epubContinuationRef = useRef<string | null>(null);
|
||||||
const pageTurnEstimateRef = useRef<PageTurnEstimate | null>(null);
|
const pageTurnEstimateRef = useRef<TTSPageTurnEstimate | null>(null);
|
||||||
const pageTurnTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const pageTurnTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -562,7 +562,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
if (smartSentenceSplitting && !isEPUB && continuationCarried && normalizedOptions.nextLocation !== undefined) {
|
if (smartSentenceSplitting && !isEPUB && continuationCarried && normalizedOptions.nextLocation !== undefined) {
|
||||||
const continuationNormalized = preprocessSentenceForAudio(continuationCarried);
|
const continuationNormalized = preprocessSentenceForAudio(continuationCarried);
|
||||||
if (continuationNormalized) {
|
if (continuationNormalized) {
|
||||||
let bestEstimate: PageTurnEstimate | null = null;
|
let bestEstimate: TTSPageTurnEstimate | null = null;
|
||||||
|
|
||||||
newSentences.forEach((sentence, index) => {
|
newSentences.forEach((sentence, index) => {
|
||||||
const normalizedSentence = preprocessSentenceForAudio(sentence);
|
const normalizedSentence = preprocessSentenceForAudio(sentence);
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,5 @@
|
||||||
export type TTSLocation = string | number;
|
export type TTSLocation = string | number;
|
||||||
|
|
||||||
// Result of merging a continuation slice into the current text
|
|
||||||
export interface ContinuationMergeResult {
|
|
||||||
text: string;
|
|
||||||
carried: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estimate for when a visual page/section turn should occur during audio playback
|
|
||||||
export interface PageTurnEstimate {
|
|
||||||
location: TTSLocation;
|
|
||||||
sentenceIndex: number;
|
|
||||||
fraction: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standardized error codes for the TTS API
|
// Standardized error codes for the TTS API
|
||||||
export type TTSErrorCode =
|
export type TTSErrorCode =
|
||||||
| 'MISSING_PARAMETERS'
|
| 'MISSING_PARAMETERS'
|
||||||
|
|
@ -55,9 +42,33 @@ export interface TTSPlaybackState {
|
||||||
currDocPages?: number;
|
currDocPages?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Options for retrying TTS requests on failure in withRetry
|
||||||
export interface TTSRetryOptions {
|
export interface TTSRetryOptions {
|
||||||
maxRetries?: number;
|
maxRetries?: number;
|
||||||
initialDelay?: number;
|
initialDelay?: number;
|
||||||
maxDelay?: number;
|
maxDelay?: number;
|
||||||
backoffFactor?: number;
|
backoffFactor?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result of merging a continuation slice into the current text
|
||||||
|
export interface TTSSmartMergeResult {
|
||||||
|
text: string;
|
||||||
|
carried: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate for when a visual page/section turn should occur during audio playback
|
||||||
|
export interface TTSPageTurnEstimate {
|
||||||
|
location: TTSLocation;
|
||||||
|
sentenceIndex: number;
|
||||||
|
fraction: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata for an audiobook chapter
|
||||||
|
export interface TTSAudiobookChapter {
|
||||||
|
index: number;
|
||||||
|
title: string;
|
||||||
|
duration?: number;
|
||||||
|
status: 'pending' | 'generating' | 'completed' | 'error';
|
||||||
|
bookId?: string;
|
||||||
|
format?: 'mp3' | 'm4b';
|
||||||
}
|
}
|
||||||
|
|
@ -318,6 +318,16 @@ test.describe('Audiobook export', () => {
|
||||||
const regeneratingLabel = page.getByText(/Regenerating/);
|
const regeneratingLabel = page.getByText(/Regenerating/);
|
||||||
await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 });
|
await expect(regeneratingLabel).toHaveCount(0, { timeout: 120_000 });
|
||||||
|
|
||||||
|
// After regeneration completes in the UI, verify backend chapter state is fully updated
|
||||||
|
// before triggering a full download to avoid races with ffmpeg concat on Alpine.
|
||||||
|
const backendStateAfterRegenerate = await expectChaptersBackendState(page, bookId);
|
||||||
|
expect(backendStateAfterRegenerate.exists).toBe(true);
|
||||||
|
expect(Array.isArray(backendStateAfterRegenerate.chapters)).toBe(true);
|
||||||
|
expect(backendStateAfterRegenerate.chapters.length).toBe(chapterCountBefore);
|
||||||
|
for (const ch of backendStateAfterRegenerate.chapters) {
|
||||||
|
expect(ch.duration).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
// Chapter count should remain exactly the same after regeneration (no duplicates)
|
// Chapter count should remain exactly the same after regeneration (no duplicates)
|
||||||
await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 });
|
await expect(chapterActionsButtons).toHaveCount(chapterCountBefore, { timeout: 20_000 });
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue