Add TXT and MD support with and HTML viewer with react markdown + many refactors to support it
This commit is contained in:
parent
b0a3e2f632
commit
82bb91a850
18 changed files with 2215 additions and 28 deletions
1508
package-lock.json
generated
1508
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -26,14 +26,17 @@
|
|||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^9.2.1",
|
||||
"react-reader": "^2.0.12",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"string-similarity": "^4.0.4",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
:root,
|
||||
html.light {
|
||||
|
|
|
|||
100
src/app/html/[id]/page.tsx
Normal file
100
src/app/html/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
'use client';
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useHTML } from '@/contexts/HTMLContext';
|
||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||
import { HTMLViewer } from '@/components/HTMLViewer';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { DocumentSettings } from '@/components/DocumentSettings';
|
||||
import { SettingsIcon } from '@/components/icons/Icons';
|
||||
import { useTTS } from "@/contexts/TTSContext";
|
||||
|
||||
export default function HTMLPage() {
|
||||
const { id } = useParams();
|
||||
const { setCurrentDocument, currDocName, clearCurrDoc } = useHTML();
|
||||
const { stop } = useTTS();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
|
||||
const loadDocument = useCallback(async () => {
|
||||
if (!isLoading) return;
|
||||
console.log('Loading new HTML document (from page.tsx)');
|
||||
stop();
|
||||
try {
|
||||
if (!id) {
|
||||
setError('Document not found');
|
||||
return;
|
||||
}
|
||||
await setCurrentDocument(id as string);
|
||||
} catch (err) {
|
||||
console.error('Error loading document:', err);
|
||||
setError('Failed to load document');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isLoading, id, setCurrentDocument, stop]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDocument();
|
||||
}, [loadDocument]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => {clearCurrDoc();}}
|
||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Documents
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-2 pb-2 border-b border-offbase">
|
||||
<div className="flex flex-wrap items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => {clearCurrDoc();}}
|
||||
className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Documents
|
||||
</Link>
|
||||
<Button
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
className="rounded-full p-1 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent"
|
||||
aria-label="View Settings"
|
||||
>
|
||||
<SettingsIcon className="w-5 h-5 hover:animate-spin-slow" />
|
||||
</Button>
|
||||
</div>
|
||||
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
|
||||
{isLoading ? 'Loading...' : currDocName}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="p-4">
|
||||
<DocumentSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<HTMLViewer className="p-4" />
|
||||
)}
|
||||
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { EPUBProvider } from '@/contexts/EPUBContext';
|
|||
import { TTSProvider } from '@/contexts/TTSContext';
|
||||
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||
import { ConfigProvider } from '@/contexts/ConfigContext';
|
||||
import { HTMLProvider } from '@/contexts/HTMLContext';
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
|
|
@ -17,7 +18,9 @@ export function Providers({ children }: { children: ReactNode }) {
|
|||
<TTSProvider>
|
||||
<PDFProvider>
|
||||
<EPUBProvider>
|
||||
{children}
|
||||
<HTMLProvider>
|
||||
{children}
|
||||
</HTMLProvider>
|
||||
</EPUBProvider>
|
||||
</PDFProvider>
|
||||
</TTSProvider>
|
||||
|
|
|
|||
|
|
@ -11,12 +11,6 @@ import { ProgressPopup } from '@/components/ProgressPopup';
|
|||
|
||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||
|
||||
interface DocViewSettingsProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
epub?: boolean;
|
||||
}
|
||||
|
||||
const viewTypes = [
|
||||
{ id: 'single', name: 'Single Page' },
|
||||
{ id: 'dual', name: 'Two Pages' },
|
||||
|
|
@ -28,7 +22,12 @@ const audioFormats = [
|
|||
{ id: 'm4b', name: 'M4B' },
|
||||
];
|
||||
|
||||
export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsProps) {
|
||||
export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||
isOpen: boolean,
|
||||
setIsOpen: (isOpen: boolean) => void,
|
||||
epub?: boolean,
|
||||
html?: boolean
|
||||
}) {
|
||||
const {
|
||||
viewType,
|
||||
skipBlank,
|
||||
|
|
@ -203,7 +202,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
|
|||
</div>}
|
||||
|
||||
<div className="space-y-4">
|
||||
{!epub && <div className="space-y-6">
|
||||
{!epub && !html && <div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Text extraction margins
|
||||
|
|
@ -347,7 +346,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
|
|||
|
||||
</div>}
|
||||
|
||||
<div className="space-y-1">
|
||||
{!html && <div className="space-y-1">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -360,7 +359,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
|
|||
<p className="text-sm text-muted pl-6">
|
||||
Automatically skip pages with no text content
|
||||
</p>
|
||||
</div>
|
||||
</div>}
|
||||
{epub && (
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center space-x-2">
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ interface DocumentUploaderProps {
|
|||
}
|
||||
|
||||
export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
||||
const { addPDFDocument: addPDF, addEPUBDocument: addEPUB } = useDocuments();
|
||||
const {
|
||||
addPDFDocument: addPDF,
|
||||
addEPUBDocument: addEPUB,
|
||||
addHTMLDocument: addHTML
|
||||
} = useDocuments();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isConverting, setIsConverting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -48,6 +52,8 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
|||
await addPDF(file);
|
||||
} else if (file.type === 'application/epub+zip') {
|
||||
await addEPUB(file);
|
||||
} else if (file.type === 'text/plain' || file.type === 'text/markdown' || file.name.endsWith('.md')) {
|
||||
await addHTML(file);
|
||||
} else if (isDev && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
|
||||
setIsUploading(false);
|
||||
setIsConverting(true);
|
||||
|
|
@ -61,13 +67,15 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
|||
setIsUploading(false);
|
||||
setIsConverting(false);
|
||||
}
|
||||
}, [addPDF, addEPUB]);
|
||||
}, [addHTML, addPDF, addEPUB]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
'application/epub+zip': ['.epub'],
|
||||
'text/plain': ['.txt'],
|
||||
'text/markdown': ['.md'],
|
||||
...(isDev ? {
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx']
|
||||
} : {})
|
||||
|
|
@ -105,7 +113,7 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
|||
{isDragActive ? 'Drop your file here' : 'Drop your file here, or click to select'}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted">
|
||||
{isDev ? 'PDF, EPUB, and DOCX files are accepted' : 'PDF and EPUB files are accepted'}
|
||||
{isDev ? 'PDF, EPUB, TXT, MD, or DOCX files are accepted' : 'PDF, EPUB, TXT, or MD files are accepted'}
|
||||
</p>
|
||||
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
|
||||
</>
|
||||
|
|
|
|||
43
src/components/HTMLViewer.tsx
Normal file
43
src/components/HTMLViewer.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useHTML } from '@/contexts/HTMLContext';
|
||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||
|
||||
interface HTMLViewerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HTMLViewer({ className = '' }: HTMLViewerProps) {
|
||||
const { currDocData, currDocName } = useHTML();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (!currDocData) {
|
||||
return <DocumentSkeleton />;
|
||||
}
|
||||
|
||||
// Check if the file is a txt file
|
||||
const isTxtFile = currDocName?.toLowerCase().endsWith('.txt');
|
||||
|
||||
return (
|
||||
<div className={`h-screen flex flex-col ${className}`} ref={containerRef}>
|
||||
<div className="z-10">
|
||||
<TTSPlayer />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className={`px-4 ${isTxtFile ? 'whitespace-pre-wrap font-mono text-sm' : 'prose dark:prose-invert'}`}>
|
||||
{isTxtFile ? (
|
||||
currDocData
|
||||
) : (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{currDocData}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ const generateDefaultFolderName = (doc1: DocumentListDocument, doc2: DocumentLis
|
|||
if (significant) {
|
||||
if (significant === 'pdf') return 'PDFs';
|
||||
if (significant === 'epub') return 'EPUBs';
|
||||
if (significant === 'txt' || significant === 'md') return 'Documents';
|
||||
return `${significant.charAt(0).toUpperCase()}${significant.slice(1)}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +62,9 @@ export function DocumentList() {
|
|||
epubDocs,
|
||||
removeEPUBDocument: removeEPUB,
|
||||
isEPUBLoading,
|
||||
htmlDocs,
|
||||
removeHTMLDocument: removeHTML,
|
||||
isHTMLLoading,
|
||||
} = useDocuments();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -112,6 +116,13 @@ export function DocumentList() {
|
|||
lastModified: doc.lastModified,
|
||||
type: 'epub' as const,
|
||||
})),
|
||||
...htmlDocs.map(doc => ({
|
||||
id: doc.id,
|
||||
name: doc.name,
|
||||
size: doc.size,
|
||||
lastModified: doc.lastModified,
|
||||
type: 'html' as const,
|
||||
})),
|
||||
];
|
||||
|
||||
const sortDocuments = useCallback((docs: DocumentListDocument[]) => {
|
||||
|
|
@ -143,8 +154,10 @@ export function DocumentList() {
|
|||
try {
|
||||
if (documentToDelete.type === 'pdf') {
|
||||
await removePDF(documentToDelete.id);
|
||||
} else {
|
||||
} else if (documentToDelete.type === 'epub') {
|
||||
await removeEPUB(documentToDelete.id);
|
||||
} else if (documentToDelete.type === 'html') {
|
||||
await removeHTML(documentToDelete.id);
|
||||
}
|
||||
|
||||
// Remove from folders if document is in one
|
||||
|
|
@ -159,7 +172,7 @@ export function DocumentList() {
|
|||
} catch (err) {
|
||||
console.error('Failed to remove document:', err);
|
||||
}
|
||||
}, [documentToDelete, removePDF, removeEPUB]);
|
||||
}, [documentToDelete, removePDF, removeEPUB, removeHTML]);
|
||||
|
||||
const handleDragStart = useCallback((doc: DocumentListDocument) => {
|
||||
if (!doc.folderId) {
|
||||
|
|
@ -272,7 +285,7 @@ export function DocumentList() {
|
|||
}
|
||||
}, [createFolder]);
|
||||
|
||||
if (isPDFLoading || isEPUBLoading) {
|
||||
if (isPDFLoading || isEPUBLoading || isHTMLLoading) {
|
||||
return <div className="w-full text-center text-muted">Loading documents...</div>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Link from 'next/link';
|
||||
import { DragEvent } from 'react';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { PDFIcon, EPUBIcon } from '@/components/icons/Icons';
|
||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||
import { DocumentListDocument } from '@/types/documents';
|
||||
|
||||
interface DocumentListItemProps {
|
||||
|
|
@ -52,7 +52,7 @@ export function DocumentListItem({
|
|||
className="document-link flex items-center align-center space-x-4 w-full truncate hover:bg-base rounded-lg p-0.5 sm:p-1 transition-colors"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{doc.type === 'pdf' ? <PDFIcon /> : <EPUBIcon />}
|
||||
{doc.type === 'pdf' ? <PDFIcon /> : doc.type === 'epub' ? <EPUBIcon /> : <FileIcon />}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02] w-full truncate">
|
||||
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
|
||||
|
|
|
|||
|
|
@ -247,3 +247,18 @@ export function EPUBIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className={props.className || "w-8 h-8 text-muted"}
|
||||
{...props}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
import { createContext, useContext, ReactNode } from 'react';
|
||||
import { usePDFDocuments } from '@/hooks/pdf/usePDFDocuments';
|
||||
import { useEPUBDocuments } from '@/hooks/epub/useEPUBDocuments';
|
||||
import { PDFDocument, EPUBDocument } from '@/types/documents';
|
||||
import { useHTMLDocuments } from '@/hooks/html/useHTMLDocuments';
|
||||
import { PDFDocument, EPUBDocument, HTMLDocument } from '@/types/documents';
|
||||
|
||||
interface DocumentContextType {
|
||||
// PDF Documents
|
||||
|
|
@ -18,11 +19,19 @@ interface DocumentContextType {
|
|||
removeEPUBDocument: (id: string) => Promise<void>;
|
||||
isEPUBLoading: boolean;
|
||||
|
||||
// HTML Documents
|
||||
htmlDocs: HTMLDocument[];
|
||||
addHTMLDocument: (file: File) => Promise<string>;
|
||||
removeHTMLDocument: (id: string) => Promise<void>;
|
||||
isHTMLLoading: boolean;
|
||||
|
||||
refreshPDFs: () => Promise<void>;
|
||||
refreshEPUBs: () => Promise<void>;
|
||||
refreshHTML: () => Promise<void>;
|
||||
|
||||
clearPDFs: () => Promise<void>;
|
||||
clearEPUBs: () => Promise<void>;
|
||||
clearHTML: () => Promise<void>;
|
||||
}
|
||||
|
||||
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
|
||||
|
|
@ -46,6 +55,15 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
|||
clearDocuments: clearEPUBs
|
||||
} = useEPUBDocuments();
|
||||
|
||||
const {
|
||||
documents: htmlDocs,
|
||||
addDocument: addHTMLDocument,
|
||||
removeDocument: removeHTMLDocument,
|
||||
isLoading: isHTMLLoading,
|
||||
refresh: refreshHTML,
|
||||
clearDocuments: clearHTML
|
||||
} = useHTMLDocuments();
|
||||
|
||||
return (
|
||||
<DocumentContext.Provider value={{
|
||||
pdfDocs,
|
||||
|
|
@ -56,10 +74,16 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
|||
addEPUBDocument,
|
||||
removeEPUBDocument,
|
||||
isEPUBLoading,
|
||||
htmlDocs,
|
||||
addHTMLDocument,
|
||||
removeHTMLDocument,
|
||||
isHTMLLoading,
|
||||
refreshPDFs,
|
||||
refreshEPUBs,
|
||||
refreshHTML,
|
||||
clearPDFs,
|
||||
clearEPUBs
|
||||
clearEPUBs,
|
||||
clearHTML
|
||||
}}>
|
||||
{children}
|
||||
</DocumentContext.Provider>
|
||||
|
|
|
|||
187
src/contexts/HTMLContext.tsx
Normal file
187
src/contexts/HTMLContext.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { indexedDBService } from '@/utils/indexedDB';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { combineAudioChunks, withRetry } from '@/utils/audio';
|
||||
|
||||
interface HTMLContextType {
|
||||
currDocData: string | undefined;
|
||||
currDocName: string | undefined;
|
||||
currDocText: string | undefined;
|
||||
setCurrentDocument: (id: string) => Promise<void>;
|
||||
clearCurrDoc: () => void;
|
||||
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, format?: 'mp3' | 'm4b') => Promise<ArrayBuffer>;
|
||||
isAudioCombining: boolean;
|
||||
}
|
||||
|
||||
const HTMLContext = createContext<HTMLContextType | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Provider component for HTML/Markdown functionality
|
||||
* Manages the state and operations for HTML document handling
|
||||
* @param {Object} props - Component props
|
||||
* @param {ReactNode} props.children - Child components to be wrapped by the provider
|
||||
*/
|
||||
export function HTMLProvider({ children }: { children: ReactNode }) {
|
||||
const { setText: setTTSText, stop } = useTTS();
|
||||
const { apiKey, baseUrl, voiceSpeed, voice } = useConfig();
|
||||
|
||||
// Current document state
|
||||
const [currDocData, setCurrDocData] = useState<string>();
|
||||
const [currDocName, setCurrDocName] = useState<string>();
|
||||
const [currDocText, setCurrDocText] = useState<string>();
|
||||
const [isAudioCombining, setIsAudioCombining] = useState(false);
|
||||
|
||||
/**
|
||||
* Clears all current document state and stops any active TTS
|
||||
*/
|
||||
const clearCurrDoc = useCallback(() => {
|
||||
setCurrDocData(undefined);
|
||||
setCurrDocName(undefined);
|
||||
setCurrDocText(undefined);
|
||||
stop();
|
||||
}, [stop]);
|
||||
|
||||
/**
|
||||
* Sets the current document based on its ID
|
||||
* @param {string} id - The unique identifier of the document
|
||||
* @throws {Error} When document data is empty or retrieval fails
|
||||
*/
|
||||
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
||||
try {
|
||||
const doc = await indexedDBService.getHTMLDocument(id);
|
||||
if (doc) {
|
||||
setCurrDocName(doc.name);
|
||||
setCurrDocData(doc.data);
|
||||
setCurrDocText(doc.data); // Use the same text for TTS
|
||||
setTTSText(doc.data);
|
||||
} else {
|
||||
console.error('Document not found in IndexedDB');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get HTML document:', error);
|
||||
clearCurrDoc();
|
||||
}
|
||||
}, [clearCurrDoc, setTTSText]);
|
||||
|
||||
/**
|
||||
* Creates a complete audiobook from the document text
|
||||
*/
|
||||
const createFullAudioBook = useCallback(async (
|
||||
onProgress: (progress: number) => void,
|
||||
signal?: AbortSignal,
|
||||
format: 'mp3' | 'm4b' = 'mp3'
|
||||
): Promise<ArrayBuffer> => {
|
||||
try {
|
||||
if (!currDocText) {
|
||||
throw new Error('No text content found in document');
|
||||
}
|
||||
|
||||
const audioChunks: { buffer: ArrayBuffer; title?: string; startTime: number }[] = [];
|
||||
const currentTime = 0;
|
||||
|
||||
try {
|
||||
const audioBuffer = await withRetry(
|
||||
async () => {
|
||||
const ttsResponse = await fetch('/api/tts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-openai-key': apiKey,
|
||||
'x-openai-base-url': baseUrl,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: currDocText,
|
||||
voice: voice,
|
||||
speed: voiceSpeed,
|
||||
format: format === 'm4b' ? 'aac' : 'mp3'
|
||||
}),
|
||||
signal
|
||||
});
|
||||
|
||||
if (!ttsResponse.ok) {
|
||||
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
|
||||
}
|
||||
|
||||
const buffer = await ttsResponse.arrayBuffer();
|
||||
if (buffer.byteLength === 0) {
|
||||
throw new Error('Received empty audio buffer from TTS');
|
||||
}
|
||||
return buffer;
|
||||
},
|
||||
{
|
||||
maxRetries: 3,
|
||||
initialDelay: 1000,
|
||||
maxDelay: 5000,
|
||||
backoffFactor: 2
|
||||
}
|
||||
);
|
||||
|
||||
audioChunks.push({
|
||||
buffer: audioBuffer,
|
||||
title: currDocName,
|
||||
startTime: currentTime
|
||||
});
|
||||
|
||||
onProgress(100); // Single chunk, so we're done when it completes
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.log('TTS request aborted');
|
||||
const partialBuffer = await combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
||||
return partialBuffer;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return combineAudioChunks(audioChunks, format, setIsAudioCombining);
|
||||
} catch (error) {
|
||||
console.error('Error creating audiobook:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [currDocText, currDocName, apiKey, baseUrl, voice, voiceSpeed]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
currDocData,
|
||||
currDocName,
|
||||
currDocText,
|
||||
setCurrentDocument,
|
||||
clearCurrDoc,
|
||||
createFullAudioBook,
|
||||
isAudioCombining,
|
||||
}), [
|
||||
currDocData,
|
||||
currDocName,
|
||||
currDocText,
|
||||
setCurrentDocument,
|
||||
clearCurrDoc,
|
||||
createFullAudioBook,
|
||||
isAudioCombining,
|
||||
]);
|
||||
|
||||
return (
|
||||
<HTMLContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</HTMLContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to consume the HTML context
|
||||
* @returns {HTMLContextType} The HTML context value
|
||||
* @throws {Error} When used outside of HTMLProvider
|
||||
*/
|
||||
export function useHTML() {
|
||||
const context = useContext(HTMLContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useHTML must be used within an HTMLProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
96
src/hooks/html/useHTMLDocuments.ts
Normal file
96
src/hooks/html/useHTMLDocuments.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { indexedDBService } from '@/utils/indexedDB';
|
||||
import type { HTMLDocument } from '@/types/documents';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
|
||||
export function useHTMLDocuments() {
|
||||
const { isDBReady } = useConfig();
|
||||
const [documents, setDocuments] = useState<HTMLDocument[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
if (isDBReady) {
|
||||
try {
|
||||
const docs = await indexedDBService.getAllHTMLDocuments();
|
||||
setDocuments(docs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load HTML documents:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [isDBReady]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDocuments();
|
||||
}, [loadDocuments]);
|
||||
|
||||
const addDocument = useCallback(async (file: File): Promise<string> => {
|
||||
const id = uuidv4();
|
||||
const content = await file.text();
|
||||
|
||||
const newDoc: HTMLDocument = {
|
||||
id,
|
||||
type: 'html',
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
data: content,
|
||||
};
|
||||
|
||||
try {
|
||||
await indexedDBService.addHTMLDocument(newDoc);
|
||||
setDocuments((prev) => [...prev, newDoc]);
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error('Failed to add HTML document:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
||||
try {
|
||||
await indexedDBService.removeHTMLDocument(id);
|
||||
setDocuments((prev) => prev.filter((doc) => doc.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove HTML document:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (isDBReady) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const docs = await indexedDBService.getAllHTMLDocuments();
|
||||
setDocuments(docs);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh documents:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [isDBReady]);
|
||||
|
||||
const clearDocuments = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await indexedDBService.clearHTMLDocuments();
|
||||
setDocuments([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear HTML documents:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
documents,
|
||||
isLoading,
|
||||
addDocument,
|
||||
removeDocument,
|
||||
refresh,
|
||||
clearDocuments,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export type DocumentType = 'pdf' | 'epub' | 'docx';
|
||||
export type DocumentType = 'pdf' | 'epub' | 'docx' | 'html';
|
||||
|
||||
export interface BaseDocument {
|
||||
id: string;
|
||||
|
|
@ -15,6 +15,11 @@ export interface PDFDocument extends BaseDocument {
|
|||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
export interface HTMLDocument extends BaseDocument {
|
||||
type: 'html';
|
||||
data: string; // Store as string since it's text content
|
||||
}
|
||||
|
||||
export interface EPUBDocument extends BaseDocument {
|
||||
type: 'epub';
|
||||
data: ArrayBuffer;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { PDFDocument, EPUBDocument, DocumentListState } from '@/types/documents';
|
||||
import { PDFDocument, EPUBDocument, HTMLDocument, DocumentListState } from '@/types/documents';
|
||||
|
||||
const DB_NAME = 'openreader-db';
|
||||
const DB_VERSION = 2; // Increased version number
|
||||
const DB_VERSION = 3; // Increased version for new store
|
||||
const PDF_STORE_NAME = 'pdf-documents';
|
||||
const EPUB_STORE_NAME = 'epub-documents';
|
||||
const HTML_STORE_NAME = 'html-documents';
|
||||
const CONFIG_STORE_NAME = 'config';
|
||||
|
||||
export interface Config {
|
||||
|
|
@ -49,6 +50,11 @@ class IndexedDBService {
|
|||
db.createObjectStore(PDF_STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(HTML_STORE_NAME)) {
|
||||
console.log('Creating HTML documents store...');
|
||||
db.createObjectStore(HTML_STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(EPUB_STORE_NAME)) {
|
||||
console.log('Creating EPUB documents store...');
|
||||
db.createObjectStore(EPUB_STORE_NAME, { keyPath: 'id' });
|
||||
|
|
@ -206,6 +212,156 @@ class IndexedDBService {
|
|||
});
|
||||
}
|
||||
|
||||
// Add HTML Document Methods
|
||||
async addHTMLDocument(document: HTMLDocument): Promise<void> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
console.log('Adding HTML document to IndexedDB:', document.name);
|
||||
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(HTML_STORE_NAME);
|
||||
|
||||
const request = store.put({
|
||||
...document,
|
||||
data: document.data
|
||||
});
|
||||
|
||||
request.onerror = (event) => {
|
||||
const error = (event.target as IDBRequest).error;
|
||||
console.error('Error adding HTML document:', error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
console.log('HTML document added successfully:', document.name);
|
||||
resolve();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in addHTMLDocument transaction:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getHTMLDocument(id: string): Promise<HTMLDocument | undefined> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
console.log('Fetching HTML document:', id);
|
||||
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(HTML_STORE_NAME);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onerror = (event) => {
|
||||
const error = (event.target as IDBRequest).error;
|
||||
console.error('Error fetching HTML document:', error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log('HTML Document fetch result:', request.result ? 'found' : 'not found');
|
||||
resolve(request.result);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getHTMLDocument transaction:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAllHTMLDocuments(): Promise<HTMLDocument[]> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
console.log('Fetching all HTML documents');
|
||||
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(HTML_STORE_NAME);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onerror = (event) => {
|
||||
const error = (event.target as IDBRequest).error;
|
||||
console.error('Error fetching all HTML documents:', error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
console.log('Retrieved HTML documents count:', request.result?.length || 0);
|
||||
resolve(request.result || []);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getAllHTMLDocuments transaction:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async removeHTMLDocument(id: string): Promise<void> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
console.log('Removing HTML document:', id);
|
||||
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(HTML_STORE_NAME);
|
||||
const request = store.delete(id);
|
||||
|
||||
request.onerror = (event) => {
|
||||
const error = (event.target as IDBRequest).error;
|
||||
console.error('Error removing HTML document:', error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
console.log('HTML document removed successfully:', id);
|
||||
resolve();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in removeHTMLDocument transaction:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async clearHTMLDocuments(): Promise<void> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
console.log('Clearing all HTML documents');
|
||||
const transaction = this.db!.transaction([HTML_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(HTML_STORE_NAME);
|
||||
const request = store.clear();
|
||||
|
||||
request.onerror = (event) => {
|
||||
const error = (event.target as IDBRequest).error;
|
||||
console.error('Error clearing HTML documents:', error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
console.log('All HTML documents cleared successfully');
|
||||
resolve();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in clearHTMLDocuments transaction:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add EPUB Document Methods
|
||||
async addEPUBDocument(document: EPUBDocument): Promise<void> {
|
||||
if (!this.db) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { Config } from "tailwindcss";
|
||||
//@plugin "@tailwindcss/typography";
|
||||
import typography from "@tailwindcss/typography";
|
||||
|
||||
export default {
|
||||
content: [
|
||||
|
|
@ -44,5 +46,5 @@ export default {
|
|||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [typography],
|
||||
} satisfies Config;
|
||||
|
|
|
|||
30
tests/files/sample.txt
Normal file
30
tests/files/sample.txt
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Phasellus facilisis odio sed mi.
|
||||
Curabitur suscipit. Nullam vel nisi. Etiam semper ipsum ut lectus. Proin aliquam, erat eget
|
||||
pharetra commodo, eros mi condimentum quam, sed commodo justo quam ut velit.
|
||||
Integer a erat. Cras laoreet ligula cursus enim. Aenean scelerisque velit et tellus.
|
||||
Vestibulum dictum aliquet sem. Nulla facilisi. Vestibulum accumsan ante vitae elit. Nulla
|
||||
erat dolor, blandit in, rutrum quis, semper pulvinar, enim. Nullam varius congue risus.
|
||||
Vivamus sollicitudin, metus ut interdum eleifend, nisi tellus pellentesque elit, tristique
|
||||
accumsan eros quam et risus. Suspendisse libero odio, mattis sit amet, aliquet eget,
|
||||
hendrerit vel, nulla. Sed vitae augue. Aliquam erat volutpat. Aliquam feugiat vulputate nisl.
|
||||
Suspendisse quis nulla pretium ante pretium mollis. Proin velit ligula, sagittis at, egestas a,
|
||||
pulvinar quis, nisl.
|
||||
|
||||
Pellentesque sit amet lectus. Praesent pulvinar, nunc quis iaculis sagittis, justo quam
|
||||
lobortis tortor, sed vestibulum dui metus venenatis est. Nunc cursus ligula. Nulla facilisi.
|
||||
Phasellus ullamcorper consectetuer ante. Duis tincidunt, urna id condimentum luctus, nibh
|
||||
ante vulputate sapien, id sagittis massa orci ut enim. Pellentesque vestibulum convallis
|
||||
sem. Nulla consequat quam ut nisl. Nullam est. Curabitur tincidunt dapibus lorem. Proin
|
||||
velit turpis, scelerisque sit amet, iaculis nec, rhoncus ac, ipsum. Phasellus lorem arcu,
|
||||
feugiat eu, gravida eu, consequat molestie, ipsum. Nullam vel est ut ipsum volutpat
|
||||
feugiat. Aenean pellentesque.
|
||||
|
||||
In mauris. Pellentesque dui nisi, iaculis eu, rhoncus in, venenatis ac, ante. Ut odio justo,
|
||||
scelerisque vel, facilisis non, commodo a, pede. Cras nec massa sit amet tortor volutpat
|
||||
varius. Donec lacinia, neque a luctus aliquet, pede massa imperdiet ante, at varius lorem
|
||||
pede sed sapien. Fusce erat nibh, aliquet in, eleifend eget, commodo eget, erat. Fusce
|
||||
consectetuer. Cras risus tortor, porttitor nec, tristique sed, convallis semper, eros. Fusce
|
||||
vulputate ipsum a mauris. Phasellus mollis. Curabitur sed urna. Aliquam nec sapien non
|
||||
nibh pulvinar convallis. Vivamus facilisis augue quis quam. Proin cursus aliquet metus.
|
||||
Suspendisse lacinia. Nulla at tellus ac turpis eleifend scelerisque. Maecenas a pede vitae
|
||||
enim commodo interdum. Donec odio. Sed sollicitudin dui vitae justo.
|
||||
Loading…
Reference in a new issue