Prepare epub work
This commit is contained in:
parent
e3167a18ea
commit
7d667b2114
9 changed files with 294 additions and 50 deletions
33
src/app/epub/[id]/page.tsx
Normal file
33
src/app/epub/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use client';
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function EPUBPage() {
|
||||
const { id } = useParams();
|
||||
|
||||
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-2">
|
||||
<Link
|
||||
href="/"
|
||||
onClick={() => { }}
|
||||
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>
|
||||
</div>
|
||||
<h1 className="ml-2 mr-2 text-md font-semibold text-foreground truncate">
|
||||
{id}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { PDFUploader } from '@/components/PDFUploader';
|
||||
import { DocumentUploader } from '@/components/DocumentUploader';
|
||||
import { DocumentList } from '@/components/DocumentList';
|
||||
import { SettingsModal } from '@/components/SettingsModal';
|
||||
import { SettingsIcon } from '@/components/icons/Icons';
|
||||
|
|
@ -23,7 +23,7 @@ export default function Home() {
|
|||
<h1 className="text-xl font-bold text-center flex-grow">OpenReader WebUI</h1>
|
||||
<p className="text-sm mt-1 text-center text-muted mb-5">A bring your own text-to-speech api web interface for reading documents with high quality voices</p>
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<PDFUploader className='max-w-xl' />
|
||||
<DocumentUploader className='max-w-xl' />
|
||||
<DocumentList />
|
||||
</div>
|
||||
<SettingsModal isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { usePDF } from '@/contexts/PDFContext';
|
|||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { PDFSkeleton } from '@/components/PDFSkeleton';
|
||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { Button } from '@headlessui/react';
|
||||
import { PDFViewSettings } from '@/components/PDFViewSettings';
|
||||
|
|
@ -16,7 +16,7 @@ const PDFViewer = dynamic(
|
|||
() => import('@/components/PDFViewer').then((module) => module.PDFViewer),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <PDFSkeleton />
|
||||
loading: () => <DocumentSkeleton />
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -118,7 +118,7 @@ export default function PDFViewerPage() {
|
|||
</div>
|
||||
{isLoading ? (
|
||||
<div className="p-4">
|
||||
<PDFSkeleton />
|
||||
<DocumentSkeleton />
|
||||
</div>
|
||||
) : (
|
||||
<PDFViewer zoomLevel={zoomLevel} />
|
||||
|
|
|
|||
|
|
@ -1,27 +1,44 @@
|
|||
import { usePDF } from '@/contexts/PDFContext';
|
||||
import { useEpubDocuments } from '@/hooks/epub/useEpubDocuments';
|
||||
import Link from 'next/link';
|
||||
import { Button, Dialog } from '@headlessui/react';
|
||||
import { Transition, TransitionChild, DialogPanel, DialogTitle } from '@headlessui/react';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
type DocumentToDelete = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'pdf' | 'epub';
|
||||
};
|
||||
|
||||
export function DocumentList() {
|
||||
const { documents, removeDocument, isDocsLoading } = usePDF();
|
||||
const { documents: pdfDocs, removeDocument: removePDF, isDocsLoading: isPDFLoading } = usePDF();
|
||||
const { documents: epubDocs, removeDocument: removeEPUB, isLoading: isEPUBLoading } = useEpubDocuments();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [documentToDelete, setDocumentToDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
const [documentToDelete, setDocumentToDelete] = useState<DocumentToDelete | null>(null);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (documentToDelete) {
|
||||
try {
|
||||
await removeDocument(documentToDelete.id);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDocumentToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove document:', err);
|
||||
if (!documentToDelete) return;
|
||||
|
||||
try {
|
||||
if (documentToDelete.type === 'pdf') {
|
||||
await removePDF(documentToDelete.id);
|
||||
} else {
|
||||
await removeEPUB(documentToDelete.id);
|
||||
}
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDocumentToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to remove document:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (isDocsLoading) {
|
||||
const allDocuments = [
|
||||
...pdfDocs.map(doc => ({ ...doc, type: 'pdf' as const })),
|
||||
...epubDocs.map(doc => ({ ...doc, type: 'epub' as const })),
|
||||
].sort((a, b) => b.lastModified - a.lastModified);
|
||||
|
||||
if (isPDFLoading || isEPUBLoading) {
|
||||
return (
|
||||
<div className="w-full text-center text-muted">
|
||||
Loading documents...
|
||||
|
|
@ -29,7 +46,7 @@ export function DocumentList() {
|
|||
);
|
||||
}
|
||||
|
||||
if (documents.length === 0) {
|
||||
if (allDocuments.length === 0) {
|
||||
return (
|
||||
<div className="w-full text-center text-muted">
|
||||
No documents uploaded yet
|
||||
|
|
@ -41,9 +58,9 @@ export function DocumentList() {
|
|||
<div className="w-full mx-auto">
|
||||
<h2 className="text-xl font-semibold mb-4 text-foreground">Your Documents</h2>
|
||||
<div className="bg-background rounded-lg shadow p-2 space-y-2">
|
||||
{documents.map((doc) => (
|
||||
{allDocuments.map((doc) => (
|
||||
<Transition
|
||||
key={doc.id}
|
||||
key={`${doc.type}-${doc.id}`}
|
||||
show={true}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
|
|
@ -52,20 +69,29 @@ export function DocumentList() {
|
|||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between hover:bg-base p-1 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between hover:bg-base p-1 rounded-lg transition-colors">
|
||||
<Link
|
||||
href={`/pdf/${encodeURIComponent(doc.id)}`}
|
||||
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||
className="flex items-center space-x-4 flex-1 min-w-0"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="w-6 h-6 sm:w-8 sm:h-8 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
{doc.type === 'pdf' ? (
|
||||
<svg className="w-6 h-6 sm:w-8 sm:h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
) : (
|
||||
<svg className="w-6 h-6 sm:w-8 sm:h-8 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 transform transition-transform duration-200 ease-in-out hover:scale-[1.02]">
|
||||
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm sm:text-md text-foreground font-medium truncate">{doc.name}</p>
|
||||
<span className="text-xs rounded-full bg-muted/20 text-muted uppercase">
|
||||
{doc.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted truncate">
|
||||
{(doc.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
|
|
@ -73,7 +99,7 @@ export function DocumentList() {
|
|||
</Link>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDocumentToDelete({ id: doc.id, name: doc.name });
|
||||
setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type });
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
className="ml-4 p-2 text-muted hover:text-red-500 rounded-lg hover:bg-red-50 transition-colors"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export function PDFSkeleton() {
|
||||
export function DocumentSkeleton() {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
toast('There might be an issue with the file import. Please try again.', {
|
||||
|
|
@ -4,36 +4,44 @@ import { useState, useCallback } from 'react';
|
|||
import { useDropzone } from 'react-dropzone';
|
||||
import { usePDF } from '@/contexts/PDFContext';
|
||||
import { UploadIcon } from '@/components/icons/Icons';
|
||||
import { useEpubDocuments } from '@/hooks/epub/useEpubDocuments';
|
||||
|
||||
interface PDFUploaderProps {
|
||||
interface DocumentUploaderProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PDFUploader({ className = '' }: PDFUploaderProps) {
|
||||
const { addDocument } = usePDF();
|
||||
export function DocumentUploader({ className = '' }: DocumentUploaderProps) {
|
||||
const { addDocument: addPDF } = usePDF();
|
||||
const { addDocument: addEPUB } = useEpubDocuments();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||
const file = acceptedFiles[0];
|
||||
if (file && file.type === 'application/pdf') {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await addDocument(file);
|
||||
} catch (err) {
|
||||
setError('Failed to upload PDF. Please try again.');
|
||||
console.error('Upload error:', err);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (file.type === 'application/pdf') {
|
||||
await addPDF(file);
|
||||
} else if (file.type === 'application/epub+zip') {
|
||||
await addEPUB(file);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to upload file. Please try again.');
|
||||
console.error('Upload error:', err);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [addDocument]);
|
||||
}, [addPDF, addEPUB]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'application/pdf': ['.pdf']
|
||||
'application/pdf': ['.pdf'],
|
||||
'application/epub+zip': ['.epub']
|
||||
},
|
||||
multiple: false,
|
||||
disabled: isUploading
|
||||
|
|
@ -56,15 +64,15 @@ export function PDFUploader({ className = '' }: PDFUploaderProps) {
|
|||
|
||||
{isUploading ? (
|
||||
<p className="text-sm sm:text-lg font-semibold text-foreground">
|
||||
Uploading PDF...
|
||||
Uploading file...
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 text-sm sm:text-lg font-semibold text-foreground">
|
||||
{isDragActive ? 'Drop your PDF here' : 'Drop your PDF here, or click to select'}
|
||||
{isDragActive ? 'Drop your file here' : 'Drop your file here, or click to select'}
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm text-muted">
|
||||
Only PDF files are currently accepted
|
||||
PDF and EPUB files are accepted
|
||||
</p>
|
||||
{error && <p className="mt-2 text-sm text-red-500">{error}</p>}
|
||||
</>
|
||||
|
|
@ -4,7 +4,7 @@ import { RefObject, useCallback, useState, useEffect, useRef } from 'react';
|
|||
import { Document, Page } from 'react-pdf';
|
||||
import 'react-pdf/dist/Page/AnnotationLayer.css';
|
||||
import 'react-pdf/dist/Page/TextLayer.css';
|
||||
import { PDFSkeleton } from './PDFSkeleton';
|
||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { usePDF } from '@/contexts/PDFContext';
|
||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||
|
|
@ -165,8 +165,8 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
|||
return (
|
||||
<div ref={containerRef} className="flex flex-col items-center overflow-auto max-h-[calc(100vh-100px)] w-full px-6">
|
||||
<Document
|
||||
loading={<PDFSkeleton />}
|
||||
noData={<PDFSkeleton />}
|
||||
loading={<DocumentSkeleton />}
|
||||
noData={<DocumentSkeleton />}
|
||||
file={currDocURL}
|
||||
onLoadSuccess={(pdf) => {
|
||||
onDocumentLoadSuccess(pdf);
|
||||
|
|
|
|||
66
src/hooks/epub/useEpubDocuments.ts
Normal file
66
src/hooks/epub/useEpubDocuments.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { indexedDBService, type EPUBDocument } from '@/utils/indexedDB';
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
|
||||
export function useEpubDocuments() {
|
||||
const { isDBReady } = useConfig();
|
||||
const [documents, setDocuments] = useState<EPUBDocument[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
if (isDBReady) {
|
||||
try {
|
||||
const docs = await indexedDBService.getAllEpubDocuments();
|
||||
setDocuments(docs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load EPUB documents:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [isDBReady]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDocuments();
|
||||
}, [loadDocuments]);
|
||||
|
||||
const addDocument = useCallback(async (file: File): Promise<string> => {
|
||||
const id = uuidv4();
|
||||
const newDoc: EPUBDocument = {
|
||||
id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
data: new Blob([file], { type: file.type }),
|
||||
};
|
||||
|
||||
try {
|
||||
await indexedDBService.addEpubDocument(newDoc);
|
||||
setDocuments((prev) => [...prev, newDoc]);
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error('Failed to add EPUB document:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeDocument = useCallback(async (id: string): Promise<void> => {
|
||||
try {
|
||||
await indexedDBService.removeEpubDocument(id);
|
||||
setDocuments((prev) => prev.filter((doc) => doc.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove EPUB document:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
documents,
|
||||
isLoading,
|
||||
addDocument,
|
||||
removeDocument,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
const DB_NAME = 'openreader-db';
|
||||
const DB_VERSION = 1;
|
||||
const DB_VERSION = 2; // Increased version number
|
||||
const PDF_STORE_NAME = 'pdf-documents';
|
||||
const EPUB_STORE_NAME = 'epub-documents';
|
||||
const CONFIG_STORE_NAME = 'config';
|
||||
|
||||
export interface PDFDocument {
|
||||
|
|
@ -11,6 +12,14 @@ export interface PDFDocument {
|
|||
data: Blob;
|
||||
}
|
||||
|
||||
export interface EPUBDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
lastModified: number;
|
||||
data: Blob;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
key: string;
|
||||
value: string;
|
||||
|
|
@ -54,6 +63,11 @@ class IndexedDBService {
|
|||
db.createObjectStore(PDF_STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(EPUB_STORE_NAME)) {
|
||||
console.log('Creating EPUB documents store...');
|
||||
db.createObjectStore(EPUB_STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
|
||||
if (!db.objectStoreNames.contains(CONFIG_STORE_NAME)) {
|
||||
console.log('Creating config store...');
|
||||
db.createObjectStore(CONFIG_STORE_NAME, { keyPath: 'key' });
|
||||
|
|
@ -181,6 +195,103 @@ class IndexedDBService {
|
|||
});
|
||||
}
|
||||
|
||||
// Add EPUB Document Methods
|
||||
async addEpubDocument(document: EPUBDocument): Promise<void> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(EPUB_STORE_NAME);
|
||||
const request = store.put(document);
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject((event.target as IDBRequest).error);
|
||||
};
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
resolve();
|
||||
};
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getEpubDocument(id: string): Promise<EPUBDocument | undefined> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(EPUB_STORE_NAME);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject((event.target as IDBRequest).error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getAllEpubDocuments(): Promise<EPUBDocument[]> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(EPUB_STORE_NAME);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject((event.target as IDBRequest).error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result || []);
|
||||
};
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async removeEpubDocument(id: string): Promise<void> {
|
||||
if (!this.db) {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const transaction = this.db!.transaction([EPUB_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(EPUB_STORE_NAME);
|
||||
const request = store.delete(id);
|
||||
|
||||
request.onerror = (event) => {
|
||||
reject((event.target as IDBRequest).error);
|
||||
};
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
resolve();
|
||||
};
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Config Methods
|
||||
async setConfigItem(key: string, value: string): Promise<void> {
|
||||
if (!this.db) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue