Skip to content

Commit 04def62

Browse files
committed
refactor(core): standardize module structure and enhance TTS types
- Reorganized utility modules from `src/utils` to `src/lib` for clearer separation of concerns. - Introduced new, dedicated type definitions in `src/types` for improved type safety in configuration and TTS API interactions. - Replaced `src/types/appConfig.ts` with `src/types/config.ts`. - Added `src/types/tts.ts` for TTS request payloads, error structures, and retry options. - Updated module imports across several contexts (`Config`, `EPUB`, `HTML`, `PDF`, `TTS`) and components to reflect the new `lib` and `types` locations. - Enhanced TTS API request and error handling in `src/app/api/tts/route.ts` and TTS-consuming contexts with explicit types. - Simplified `ProgressCard`, `ProgressPopup`, and `AudiobookExportModal` components by removing the `isProcessing` prop, centralizing processing state management. - Streamlined `HTMLContext` by removing `createFullAudioBook` and `isAudioCombining` properties, focusing its scope.
1 parent ad4faa3 commit 04def62

26 files changed

+309
-388
lines changed

src/app/api/tts/route.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
44
import { isKokoroModel } from '@/utils/voice';
55
import { LRUCache } from 'lru-cache';
66
import { createHash } from 'crypto';
7+
import type { TTSRequestPayload, TTSError } from '@/types/tts';
78

89
export const runtime = 'nodejs';
910

@@ -102,14 +103,20 @@ export async function POST(req: NextRequest) {
102103
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
103104
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
104105
const provider = req.headers.get('x-tts-provider') || 'openai';
105-
const { text, voice, speed, format, model: req_model, instructions } = await req.json();
106+
const body = (await req.json()) as TTSRequestPayload;
107+
const { text, voice, speed, format, model: req_model, instructions } = body;
106108
console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) });
107109

108110
if (!text || !voice || !speed) {
109-
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
111+
const errorBody: TTSError = {
112+
code: 'MISSING_PARAMETERS',
113+
message: 'Missing required parameters',
114+
};
115+
return NextResponse.json(errorBody, { status: 400 });
110116
}
111-
// Use default Kokoro model for Deepinfra if none specified
112-
const model = provider === 'deepinfra' && !req_model ? 'hexgrad/Kokoro-82M' : req_model;
117+
// Use default Kokoro model for Deepinfra if none specified, then fall back to a safe default
118+
const rawModel = provider === 'deepinfra' && !req_model ? 'hexgrad/Kokoro-82M' : req_model;
119+
const model: SpeechCreateParams['model'] = (rawModel ?? 'gpt-4o-mini-tts') as SpeechCreateParams['model'];
113120

114121
// Initialize OpenAI client with abort signal (OpenAI/deepinfra)
115122
const openai = new OpenAI({
@@ -118,7 +125,7 @@ export async function POST(req: NextRequest) {
118125
});
119126

120127
const normalizedVoice = (
121-
!isKokoroModel(model) && voice.includes('+')
128+
!isKokoroModel(model as string) && voice.includes('+')
122129
? (voice.split('+')[0].trim())
123130
: voice
124131
) as SpeechCreateParams['voice'];
@@ -131,7 +138,7 @@ export async function POST(req: NextRequest) {
131138
response_format: format === 'aac' ? 'aac' : 'mp3',
132139
};
133140
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
134-
if (model === 'gpt-4o-mini-tts' && instructions) {
141+
if ((model as string) === 'gpt-4o-mini-tts' && instructions) {
135142
createParams.instructions = instructions;
136143
}
137144

@@ -263,8 +270,13 @@ export async function POST(req: NextRequest) {
263270
}
264271

265272
console.warn('Error generating TTS:', error);
273+
const errorBody: TTSError = {
274+
code: 'TTS_GENERATION_FAILED',
275+
message: 'Failed to generate audio',
276+
details: process.env.NODE_ENV !== 'production' ? String(error) : undefined,
277+
};
266278
return NextResponse.json(
267-
{ error: 'Failed to generate audio' },
279+
errorBody,
268280
{ status: 500 }
269281
);
270282
}

src/components/AudiobookExportModal.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,6 @@ export function AudiobookExportModal({
388388
progress={progress}
389389
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
390390
onCancel={handleCancel}
391-
isProcessing={isCombining}
392391
cancelText="Cancel"
393392
operationType="audiobook"
394393
onClick={() => setIsOpen(true)}
@@ -531,7 +530,6 @@ export function AudiobookExportModal({
531530
progress={progress}
532531
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
533532
onCancel={handleCancel}
534-
isProcessing={isCombining}
535533
operationType="audiobook"
536534
currentChapter={currentChapter}
537535
completedChapters={chapters.filter(c => c.status === 'completed').length}

src/components/PDFViewer.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { RefObject, useCallback, useState, useEffect, useRef } from 'react';
44
import { Document, Page } from 'react-pdf';
5+
import type { Dest } from 'react-pdf/src/shared/types.js';
56
import 'react-pdf/dist/Page/AnnotationLayer.css';
67
import 'react-pdf/dist/Page/TextLayer.css';
78
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
@@ -14,9 +15,9 @@ interface PDFViewerProps {
1415
zoomLevel: number;
1516
}
1617

17-
interface OnItemClickArgs {
18+
interface PDFOnLinkClickArgs {
1819
pageNumber?: number;
19-
dest?: unknown;
20+
dest?: Dest;
2021
}
2122

2223
export function PDFViewer({ zoomLevel }: PDFViewerProps) {
@@ -126,12 +127,12 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
126127
onLoadSuccess={(pdf) => {
127128
onDocumentLoadSuccess(pdf);
128129
}}
129-
onItemClick={(args: OnItemClickArgs) => {
130+
onItemClick={(args: PDFOnLinkClickArgs) => {
130131
if (args?.pageNumber) {
131132
skipToLocation(args.pageNumber, true);
132133
} else if (args?.dest) {
133-
const destArray = Array.isArray(args.dest) ? args.dest : [];
134-
const pageNum = typeof destArray[0] === 'number' ? destArray[0] + 1 : undefined;
134+
const destArray = args.dest as Array<number> || [];
135+
const pageNum = destArray[0] + 1 || null;
135136
if (pageNum) {
136137
skipToLocation(pageNum, true);
137138
}

src/components/ProgressCard.tsx

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
import { LoadingSpinner } from './Spinner';
2-
31
interface ProgressCardProps {
42
progress: number;
53
estimatedTimeRemaining?: string;
64
onCancel: (e?: React.MouseEvent) => void;
7-
isProcessing?: boolean;
85
operationType?: 'sync' | 'load' | 'audiobook';
96
cancelText?: string;
107
currentChapter?: string;
@@ -16,7 +13,6 @@ export function ProgressCard({
1613
progress,
1714
estimatedTimeRemaining,
1815
onCancel,
19-
isProcessing = false,
2016
operationType,
2117
cancelText = 'Cancel',
2218
currentChapter,
@@ -58,11 +54,6 @@ export function ProgressCard({
5854
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"
5955
onClick={(e) => onCancel(e)}
6056
>
61-
{isProcessing && (
62-
<span className="w-3 h-3">
63-
<LoadingSpinner />
64-
</span>
65-
)}
6657
<span>{cancelText}</span>
6758
</button>
6859
</div>

src/components/ProgressPopup.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ interface ProgressPopupProps {
77
progress: number;
88
estimatedTimeRemaining?: string;
99
onCancel: () => void;
10-
isProcessing: boolean;
1110
statusMessage?: string;
1211
operationType?: 'sync' | 'load' | 'audiobook';
1312
cancelText?: string;
@@ -22,7 +21,6 @@ export function ProgressPopup({
2221
progress,
2322
estimatedTimeRemaining,
2423
onCancel,
25-
isProcessing,
2624
statusMessage,
2725
operationType,
2826
cancelText = 'Cancel',
@@ -56,7 +54,6 @@ export function ProgressPopup({
5654
e?.stopPropagation();
5755
onCancel();
5856
}}
59-
isProcessing={isProcessing}
6057
operationType={operationType}
6158
cancelText={cancelText}
6259
currentChapter={currentChapter}

src/components/SettingsModal.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
import { useTheme } from '@/contexts/ThemeContext';
2323
import { useConfig } from '@/contexts/ConfigContext';
2424
import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons';
25-
import { syncDocumentsToServer, loadDocumentsFromServer, getFirstVisit, setFirstVisit } from '@/utils/dexie';
25+
import { syncDocumentsToServer, loadDocumentsFromServer, getFirstVisit, setFirstVisit } from '@/lib/dexie';
2626
import { useDocuments } from '@/contexts/DocumentContext';
2727
import { ConfirmDialog } from '@/components/ConfirmDialog';
2828
import { ProgressPopup } from '@/components/ProgressPopup';
@@ -699,7 +699,6 @@ export function SettingsModal() {
699699
setOperationType('sync');
700700
setAbortController(null);
701701
}}
702-
isProcessing={isSyncing || isLoading}
703702
statusMessage={statusMessage}
704703
operationType={operationType}
705704
cancelText="Cancel"

src/components/doclist/DocumentList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useDocuments } from '@/contexts/DocumentContext';
55
import { DndProvider } from 'react-dnd';
66
import { HTML5Backend } from 'react-dnd-html5-backend';
77
import { DocumentType, DocumentListDocument, Folder, DocumentListState, SortBy, SortDirection } from '@/types/documents';
8-
import { getDocumentListState, saveDocumentListState } from '@/utils/dexie';
8+
import { getDocumentListState, saveDocumentListState } from '@/lib/dexie';
99
import { ConfirmDialog } from '@/components/ConfirmDialog';
1010
import { DocumentListItem } from '@/components/doclist/DocumentListItem';
1111
import { DocumentFolder } from '@/components/doclist/DocumentFolder';

src/contexts/ConfigContext.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22

33
import { createContext, useContext, useEffect, useMemo, useState, ReactNode } from 'react';
44
import { useLiveQuery } from 'dexie-react-hooks';
5-
import { db, initDB, updateAppConfig } from '@/utils/dexie';
6-
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/appConfig';
7-
export type { ViewType } from '@/types/appConfig';
5+
import { db, initDB, updateAppConfig } from '@/lib/dexie';
6+
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
7+
export type { ViewType } from '@/types/config';
88

99
/** Configuration values for the application */
10-
type ConfigValues = Omit<AppConfigRow, 'id'>;
1110

1211
/** Interface defining the configuration context shape and functionality */
1312
interface ConfigContextType {
@@ -29,7 +28,7 @@ interface ConfigContextType {
2928
ttsInstructions: string;
3029
savedVoices: SavedVoices;
3130
updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => Promise<void>;
32-
updateConfigKey: <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => Promise<void>;
31+
updateConfigKey: <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => Promise<void>;
3332
isLoading: boolean;
3433
isDBReady: boolean;
3534
pdfHighlightEnabled: boolean;
@@ -76,7 +75,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
7675
null,
7776
);
7877

79-
const config: ConfigValues | null = useMemo(() => {
78+
const config: AppConfigValues | null = useMemo(() => {
8079
if (!appConfig) return null;
8180
const { id, ...rest } = appConfig;
8281
void id;
@@ -131,9 +130,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
131130
/**
132131
* Updates a single configuration value by key
133132
* @param {K} key - The configuration key to update
134-
* @param {ConfigValues[K]} value - The new value for the configuration
133+
* @param {AppConfigValues[K]} value - The new value for the configuration
135134
*/
136-
const updateConfigKey = async <K extends keyof ConfigValues>(key: K, value: ConfigValues[K]) => {
135+
const updateConfigKey = async <K extends keyof AppConfigValues>(key: K, value: AppConfigValues[K]) => {
137136
try {
138137
setIsLoading(true);
139138

@@ -153,7 +152,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
153152
const voiceKey = getVoiceKey(newProvider, newModel);
154153
const restoredVoice = savedVoices[voiceKey] || '';
155154
await updateAppConfig({
156-
[key]: value as ConfigValues[keyof ConfigValues],
155+
[key]: value as AppConfigValues[keyof AppConfigValues],
157156
voice: restoredVoice,
158157
} as Partial<AppConfigRow>);
159158
}
@@ -165,7 +164,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
165164
}
166165
else {
167166
await updateAppConfig({
168-
[key]: value as ConfigValues[keyof ConfigValues],
167+
[key]: value as AppConfigValues[keyof AppConfigValues],
169168
} as Partial<AppConfigRow>);
170169
}
171170
} catch (error) {

0 commit comments

Comments
 (0)