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-dom": "^19.0.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-hot-toast": "^2.5.2",
|
"react-hot-toast": "^2.5.2",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-pdf": "^9.2.1",
|
"react-pdf": "^9.2.1",
|
||||||
"react-reader": "^2.0.12",
|
"react-reader": "^2.0.12",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"string-similarity": "^4.0.4",
|
"string-similarity": "^4.0.4",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@playwright/test": "^1.50.1",
|
"@playwright/test": "^1.50.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
:root,
|
:root,
|
||||||
html.light {
|
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 { TTSProvider } from '@/contexts/TTSContext';
|
||||||
import { ThemeProvider } from '@/contexts/ThemeContext';
|
import { ThemeProvider } from '@/contexts/ThemeContext';
|
||||||
import { ConfigProvider } from '@/contexts/ConfigContext';
|
import { ConfigProvider } from '@/contexts/ConfigContext';
|
||||||
|
import { HTMLProvider } from '@/contexts/HTMLContext';
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -17,7 +18,9 @@ export function Providers({ children }: { children: ReactNode }) {
|
||||||
<TTSProvider>
|
<TTSProvider>
|
||||||
<PDFProvider>
|
<PDFProvider>
|
||||||
<EPUBProvider>
|
<EPUBProvider>
|
||||||
{children}
|
<HTMLProvider>
|
||||||
|
{children}
|
||||||
|
</HTMLProvider>
|
||||||
</EPUBProvider>
|
</EPUBProvider>
|
||||||
</PDFProvider>
|
</PDFProvider>
|
||||||
</TTSProvider>
|
</TTSProvider>
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,6 @@ import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
interface DocViewSettingsProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
|
||||||
epub?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewTypes = [
|
const viewTypes = [
|
||||||
{ id: 'single', name: 'Single Page' },
|
{ id: 'single', name: 'Single Page' },
|
||||||
{ id: 'dual', name: 'Two Pages' },
|
{ id: 'dual', name: 'Two Pages' },
|
||||||
|
|
@ -28,7 +22,12 @@ const audioFormats = [
|
||||||
{ id: 'm4b', name: 'M4B' },
|
{ 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 {
|
const {
|
||||||
viewType,
|
viewType,
|
||||||
skipBlank,
|
skipBlank,
|
||||||
|
|
@ -203,7 +202,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!epub && <div className="space-y-6">
|
{!epub && !html && <div className="space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<label className="block text-sm font-medium text-foreground">
|
||||||
Text extraction margins
|
Text extraction margins
|
||||||
|
|
@ -347,7 +346,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
|
||||||
|
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
<div className="space-y-1">
|
{!html && <div className="space-y-1">
|
||||||
<label className="flex items-center space-x-2">
|
<label className="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -360,7 +359,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub }: DocViewSettingsPro
|
||||||
<p className="text-sm text-muted pl-6">
|
<p className="text-sm text-muted pl-6">
|
||||||
Automatically skip pages with no text content
|
Automatically skip pages with no text content
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>}
|
||||||
{epub && (
|
{epub && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="flex items-center space-x-2">
|
<label className="flex items-center space-x-2">
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,11 @@ interface DocumentUploaderProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentUploader({ className = '' }: 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 [isUploading, setIsUploading] = useState(false);
|
||||||
const [isConverting, setIsConverting] = useState(false);
|
const [isConverting, setIsConverting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -48,6 +52,8 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
||||||
await addPDF(file);
|
await addPDF(file);
|
||||||
} else if (file.type === 'application/epub+zip') {
|
} else if (file.type === 'application/epub+zip') {
|
||||||
await addEPUB(file);
|
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') {
|
} else if (isDev && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
setIsConverting(true);
|
setIsConverting(true);
|
||||||
|
|
@ -61,13 +67,15 @@ export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
setIsConverting(false);
|
setIsConverting(false);
|
||||||
}
|
}
|
||||||
}, [addPDF, addEPUB]);
|
}, [addHTML, addPDF, addEPUB]);
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
onDrop,
|
onDrop,
|
||||||
accept: {
|
accept: {
|
||||||
'application/pdf': ['.pdf'],
|
'application/pdf': ['.pdf'],
|
||||||
'application/epub+zip': ['.epub'],
|
'application/epub+zip': ['.epub'],
|
||||||
|
'text/plain': ['.txt'],
|
||||||
|
'text/markdown': ['.md'],
|
||||||
...(isDev ? {
|
...(isDev ? {
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx']
|
'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'}
|
{isDragActive ? 'Drop your file here' : 'Drop your file here, or click to select'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs sm:text-sm text-muted">
|
<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>
|
</p>
|
||||||
{error && <p className="mt-2 text-sm text-red-500">{error}</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) {
|
||||||
if (significant === 'pdf') return 'PDFs';
|
if (significant === 'pdf') return 'PDFs';
|
||||||
if (significant === 'epub') return 'EPUBs';
|
if (significant === 'epub') return 'EPUBs';
|
||||||
|
if (significant === 'txt' || significant === 'md') return 'Documents';
|
||||||
return `${significant.charAt(0).toUpperCase()}${significant.slice(1)}`;
|
return `${significant.charAt(0).toUpperCase()}${significant.slice(1)}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -61,6 +62,9 @@ export function DocumentList() {
|
||||||
epubDocs,
|
epubDocs,
|
||||||
removeEPUBDocument: removeEPUB,
|
removeEPUBDocument: removeEPUB,
|
||||||
isEPUBLoading,
|
isEPUBLoading,
|
||||||
|
htmlDocs,
|
||||||
|
removeHTMLDocument: removeHTML,
|
||||||
|
isHTMLLoading,
|
||||||
} = useDocuments();
|
} = useDocuments();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -112,6 +116,13 @@ export function DocumentList() {
|
||||||
lastModified: doc.lastModified,
|
lastModified: doc.lastModified,
|
||||||
type: 'epub' as const,
|
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[]) => {
|
const sortDocuments = useCallback((docs: DocumentListDocument[]) => {
|
||||||
|
|
@ -143,8 +154,10 @@ export function DocumentList() {
|
||||||
try {
|
try {
|
||||||
if (documentToDelete.type === 'pdf') {
|
if (documentToDelete.type === 'pdf') {
|
||||||
await removePDF(documentToDelete.id);
|
await removePDF(documentToDelete.id);
|
||||||
} else {
|
} else if (documentToDelete.type === 'epub') {
|
||||||
await removeEPUB(documentToDelete.id);
|
await removeEPUB(documentToDelete.id);
|
||||||
|
} else if (documentToDelete.type === 'html') {
|
||||||
|
await removeHTML(documentToDelete.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from folders if document is in one
|
// Remove from folders if document is in one
|
||||||
|
|
@ -159,7 +172,7 @@ export function DocumentList() {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to remove document:', err);
|
console.error('Failed to remove document:', err);
|
||||||
}
|
}
|
||||||
}, [documentToDelete, removePDF, removeEPUB]);
|
}, [documentToDelete, removePDF, removeEPUB, removeHTML]);
|
||||||
|
|
||||||
const handleDragStart = useCallback((doc: DocumentListDocument) => {
|
const handleDragStart = useCallback((doc: DocumentListDocument) => {
|
||||||
if (!doc.folderId) {
|
if (!doc.folderId) {
|
||||||
|
|
@ -272,7 +285,7 @@ export function DocumentList() {
|
||||||
}
|
}
|
||||||
}, [createFolder]);
|
}, [createFolder]);
|
||||||
|
|
||||||
if (isPDFLoading || isEPUBLoading) {
|
if (isPDFLoading || isEPUBLoading || isHTMLLoading) {
|
||||||
return <div className="w-full text-center text-muted">Loading documents...</div>;
|
return <div className="w-full text-center text-muted">Loading documents...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { DragEvent } from 'react';
|
import { DragEvent } from 'react';
|
||||||
import { Button } from '@headlessui/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';
|
import { DocumentListDocument } from '@/types/documents';
|
||||||
|
|
||||||
interface DocumentListItemProps {
|
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"
|
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">
|
<div className="flex-shrink-0">
|
||||||
{doc.type === 'pdf' ? <PDFIcon /> : <EPUBIcon />}
|
{doc.type === 'pdf' ? <PDFIcon /> : doc.type === 'epub' ? <EPUBIcon /> : <FileIcon />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02] w-full truncate">
|
<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>
|
<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>
|
</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 { createContext, useContext, ReactNode } from 'react';
|
||||||
import { usePDFDocuments } from '@/hooks/pdf/usePDFDocuments';
|
import { usePDFDocuments } from '@/hooks/pdf/usePDFDocuments';
|
||||||
import { useEPUBDocuments } from '@/hooks/epub/useEPUBDocuments';
|
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 {
|
interface DocumentContextType {
|
||||||
// PDF Documents
|
// PDF Documents
|
||||||
|
|
@ -18,11 +19,19 @@ interface DocumentContextType {
|
||||||
removeEPUBDocument: (id: string) => Promise<void>;
|
removeEPUBDocument: (id: string) => Promise<void>;
|
||||||
isEPUBLoading: boolean;
|
isEPUBLoading: boolean;
|
||||||
|
|
||||||
|
// HTML Documents
|
||||||
|
htmlDocs: HTMLDocument[];
|
||||||
|
addHTMLDocument: (file: File) => Promise<string>;
|
||||||
|
removeHTMLDocument: (id: string) => Promise<void>;
|
||||||
|
isHTMLLoading: boolean;
|
||||||
|
|
||||||
refreshPDFs: () => Promise<void>;
|
refreshPDFs: () => Promise<void>;
|
||||||
refreshEPUBs: () => Promise<void>;
|
refreshEPUBs: () => Promise<void>;
|
||||||
|
refreshHTML: () => Promise<void>;
|
||||||
|
|
||||||
clearPDFs: () => Promise<void>;
|
clearPDFs: () => Promise<void>;
|
||||||
clearEPUBs: () => Promise<void>;
|
clearEPUBs: () => Promise<void>;
|
||||||
|
clearHTML: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
|
const DocumentContext = createContext<DocumentContextType | undefined>(undefined);
|
||||||
|
|
@ -46,6 +55,15 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
||||||
clearDocuments: clearEPUBs
|
clearDocuments: clearEPUBs
|
||||||
} = useEPUBDocuments();
|
} = useEPUBDocuments();
|
||||||
|
|
||||||
|
const {
|
||||||
|
documents: htmlDocs,
|
||||||
|
addDocument: addHTMLDocument,
|
||||||
|
removeDocument: removeHTMLDocument,
|
||||||
|
isLoading: isHTMLLoading,
|
||||||
|
refresh: refreshHTML,
|
||||||
|
clearDocuments: clearHTML
|
||||||
|
} = useHTMLDocuments();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentContext.Provider value={{
|
<DocumentContext.Provider value={{
|
||||||
pdfDocs,
|
pdfDocs,
|
||||||
|
|
@ -56,10 +74,16 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
||||||
addEPUBDocument,
|
addEPUBDocument,
|
||||||
removeEPUBDocument,
|
removeEPUBDocument,
|
||||||
isEPUBLoading,
|
isEPUBLoading,
|
||||||
|
htmlDocs,
|
||||||
|
addHTMLDocument,
|
||||||
|
removeHTMLDocument,
|
||||||
|
isHTMLLoading,
|
||||||
refreshPDFs,
|
refreshPDFs,
|
||||||
refreshEPUBs,
|
refreshEPUBs,
|
||||||
|
refreshHTML,
|
||||||
clearPDFs,
|
clearPDFs,
|
||||||
clearEPUBs
|
clearEPUBs,
|
||||||
|
clearHTML
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</DocumentContext.Provider>
|
</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 {
|
export interface BaseDocument {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -15,6 +15,11 @@ export interface PDFDocument extends BaseDocument {
|
||||||
data: ArrayBuffer;
|
data: ArrayBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HTMLDocument extends BaseDocument {
|
||||||
|
type: 'html';
|
||||||
|
data: string; // Store as string since it's text content
|
||||||
|
}
|
||||||
|
|
||||||
export interface EPUBDocument extends BaseDocument {
|
export interface EPUBDocument extends BaseDocument {
|
||||||
type: 'epub';
|
type: 'epub';
|
||||||
data: ArrayBuffer;
|
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_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 PDF_STORE_NAME = 'pdf-documents';
|
||||||
const EPUB_STORE_NAME = 'epub-documents';
|
const EPUB_STORE_NAME = 'epub-documents';
|
||||||
|
const HTML_STORE_NAME = 'html-documents';
|
||||||
const CONFIG_STORE_NAME = 'config';
|
const CONFIG_STORE_NAME = 'config';
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
|
|
@ -49,6 +50,11 @@ class IndexedDBService {
|
||||||
db.createObjectStore(PDF_STORE_NAME, { keyPath: 'id' });
|
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)) {
|
if (!db.objectStoreNames.contains(EPUB_STORE_NAME)) {
|
||||||
console.log('Creating EPUB documents store...');
|
console.log('Creating EPUB documents store...');
|
||||||
db.createObjectStore(EPUB_STORE_NAME, { keyPath: 'id' });
|
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
|
// Add EPUB Document Methods
|
||||||
async addEPUBDocument(document: EPUBDocument): Promise<void> {
|
async addEPUBDocument(document: EPUBDocument): Promise<void> {
|
||||||
if (!this.db) {
|
if (!this.db) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
//@plugin "@tailwindcss/typography";
|
||||||
|
import typography from "@tailwindcss/typography";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: [
|
content: [
|
||||||
|
|
@ -44,5 +46,5 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [typography],
|
||||||
} satisfies Config;
|
} 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