Merge pull request #58 from RobbyV2/feat/SaveLoadProgress

This commit is contained in:
Richard Roberson 2025-07-20 12:13:07 -06:00 committed by GitHub
commit 3d1ff23904
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 150 additions and 28 deletions

View file

@ -133,6 +133,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
onCancel={handleCancel}
isProcessing={epub ? isAudioCombining : isPDFAudioCombining}
cancelText="Cancel and download"
/>
<Transition appear show={isOpen} as={Fragment}>

View file

@ -8,9 +8,12 @@ interface ProgressPopupProps {
estimatedTimeRemaining?: string;
onCancel: () => void;
isProcessing: boolean;
statusMessage?: string;
operationType?: 'sync' | 'load';
cancelText?: string;
}
export function ProgressPopup({ isOpen, progress, estimatedTimeRemaining, onCancel, isProcessing }: ProgressPopupProps) {
export function ProgressPopup({ isOpen, progress, estimatedTimeRemaining, onCancel, isProcessing, statusMessage, operationType, cancelText = 'Cancel' }: ProgressPopupProps) {
return (
<Transition
show={isOpen}
@ -22,7 +25,7 @@ export function ProgressPopup({ isOpen, progress, estimatedTimeRemaining, onCanc
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-4"
>
<div className="fixed inset-x-0 top-2 z-50 pointer-events-none">
<div className="fixed inset-x-0 top-2 z-[60] pointer-events-none">
<div className="w-full max-w-sm mx-auto">
<div className="w-full transform rounded-lg bg-offbase p-4 shadow-xl pointer-events-auto">
<div className="space-y-2 truncate">
@ -33,28 +36,32 @@ export function ProgressPopup({ isOpen, progress, estimatedTimeRemaining, onCanc
/>
</div>
<div className="flex justify-between items-center text-sm text-muted text-xs sm:text-sm">
<div className="flex flex-wrap items-center gap-1">
<span>{Math.round(progress)}% complete</span>
{estimatedTimeRemaining && <div>
<span>&bull;</span>
<span>{` ~${estimatedTimeRemaining}`}</span>
</div>}
<div className="flex flex-col gap-1">
{operationType && (
<span className="text-accent font-semibold text-xs uppercase tracking-wide">
{operationType === 'sync' ? 'Saving to Server' : 'Loading from Server'}
</span>
)}
{statusMessage && <span className="text-foreground font-medium">{statusMessage}</span>}
<div className="flex flex-wrap items-center gap-1">
<span>{Math.round(progress)}% complete</span>
{estimatedTimeRemaining && <div>
<span>&bull;</span>
<span>{` ~${estimatedTimeRemaining}`}</span>
</div>}
</div>
</div>
<button
type="button"
className="inline-flex justify-center rounded-lg px-2.5 py-1
font-medium text-foreground hover:text-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
className="inline-flex items-center justify-center gap-1 rounded-lg px-2.5 py-1 font-medium text-foreground hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
onClick={onCancel}
>
{isProcessing ? (
<div className="w-full h-full flex items-center justify-end">
{isProcessing && (
<span className="relative inline-block w-4 h-4">
<LoadingSpinner />
</div>
) : (
'Cancel and download'
</span>
)}
<span>{cancelText}</span>
</button>
</div>
</div>

View file

@ -26,6 +26,8 @@ import { indexedDBService } from '@/utils/indexedDB';
import { useDocuments } from '@/contexts/DocumentContext';
import { setItem, getItem } from '@/utils/indexedDB';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ProgressPopup } from '@/components/ProgressPopup';
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
import { THEMES } from '@/contexts/ThemeContext';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@ -48,9 +50,14 @@ export function SettingsModal() {
const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions);
const [isSyncing, setIsSyncing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [showProgress, setShowProgress] = useState(false);
const [statusMessage, setStatusMessage] = useState('');
const [operationType, setOperationType] = useState<'sync' | 'load'>('sync');
const [abortController, setAbortController] = useState<AbortController | null>(null);
const selectedTheme = themes.find(t => t.id === theme) || themes[0];
const [showClearLocalConfirm, setShowClearLocalConfirm] = useState(false);
const [showClearServerConfirm, setShowClearServerConfirm] = useState(false);
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const ttsModels = useMemo(() => [
{ id: 'tts-1', name: 'TTS-1' },
@ -84,25 +91,69 @@ export function SettingsModal() {
}, [apiKey, baseUrl, ttsModel, ttsModels, ttsInstructions, checkFirstVist]);
const handleSync = async () => {
const controller = new AbortController();
setAbortController(controller);
try {
setIsSyncing(true);
await indexedDBService.syncToServer();
setShowProgress(true);
setProgress(0);
setOperationType('sync');
setStatusMessage('Preparing documents...');
await indexedDBService.syncToServer((progress, status) => {
if (controller.signal.aborted) return;
setProgress(progress);
if (status) setStatusMessage(status);
}, controller.signal);
} catch (error) {
console.error('Sync failed:', error);
if (controller.signal.aborted) {
console.log('Sync operation cancelled');
setStatusMessage('Operation cancelled');
} else {
console.error('Sync failed:', error);
setStatusMessage('Sync failed. Please try again.');
}
} finally {
setIsSyncing(false);
setShowProgress(false);
setProgress(0);
setStatusMessage('');
setAbortController(null);
}
};
const handleLoad = async () => {
const controller = new AbortController();
setAbortController(controller);
try {
setIsLoading(true);
await indexedDBService.loadFromServer();
setShowProgress(true);
setProgress(0);
setOperationType('load');
setStatusMessage('Downloading documents from server...');
await indexedDBService.loadFromServer((progress, status) => {
if (controller.signal.aborted) return;
setProgress(progress);
if (status) setStatusMessage(status);
}, controller.signal);
if (controller.signal.aborted) return;
setStatusMessage('Refreshing document list...');
await Promise.all([refreshPDFs(), refreshEPUBs()]);
} catch (error) {
console.error('Load failed:', error);
if (controller.signal.aborted) {
console.log('Load operation cancelled');
setStatusMessage('Operation cancelled');
} else {
console.error('Load failed:', error);
setStatusMessage('Load failed. Please try again.');
}
} finally {
setIsLoading(false);
setShowProgress(false);
setProgress(0);
setStatusMessage('');
setAbortController(null);
}
};
@ -430,7 +481,7 @@ export function SettingsModal() {
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
{isLoading ? 'Loading...' : 'Load docs from Server'}
{isLoading ? `Loading... ${Math.round(progress)}%` : 'Load docs from Server'}
</Button>
<Button
onClick={handleSync}
@ -441,7 +492,7 @@ export function SettingsModal() {
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
{isSyncing ? 'Saving...' : 'Save local to Server'}
{isSyncing ? `Saving... ${Math.round(progress)}%` : 'Save local to Server'}
</Button>
</div>
</div>}
@ -498,6 +549,28 @@ export function SettingsModal() {
confirmText="Delete"
isDangerous={true}
/>
<ProgressPopup
isOpen={showProgress}
progress={progress}
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
onCancel={() => {
if (abortController) {
abortController.abort();
}
setShowProgress(false);
setProgress(0);
setIsSyncing(false);
setIsLoading(false);
setStatusMessage('');
setOperationType('sync');
setAbortController(null);
}}
isProcessing={isSyncing || isLoading}
statusMessage={statusMessage}
operationType={operationType}
cancelText="Cancel"
/>
</>
);
}

View file

@ -635,11 +635,13 @@ class IndexedDBService {
});
}
async syncToServer(): Promise<{ lastSync: number }> {
async syncToServer(onProgress?: (progress: number, status?: string) => void, signal?: AbortSignal): Promise<{ lastSync: number }> {
const pdfDocs = await this.getAllDocuments();
const epubDocs = await this.getAllEPUBDocuments();
const documents = [];
const totalDocs = pdfDocs.length + epubDocs.length;
let processedDocs = 0;
// Process PDF documents - convert ArrayBuffer to array for JSON serialization
for (const doc of pdfDocs) {
@ -648,6 +650,10 @@ class IndexedDBService {
type: 'pdf',
data: Array.from(new Uint8Array(doc.data))
});
processedDocs++;
if (onProgress) {
onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
}
}
// Process EPUB documents
@ -657,31 +663,57 @@ class IndexedDBService {
type: 'epub',
data: Array.from(new Uint8Array(doc.data))
});
processedDocs++;
if (onProgress) {
onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
}
}
if (onProgress) {
onProgress(50, 'Uploading to server...');
}
const response = await fetch('/api/documents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documents })
body: JSON.stringify({ documents }),
signal
});
if (!response.ok) {
throw new Error('Failed to sync documents to server');
}
if (onProgress) {
onProgress(100, 'Upload complete!');
}
return { lastSync: Date.now() };
}
async loadFromServer(): Promise<{ lastSync: number }> {
const response = await fetch('/api/documents');
async loadFromServer(onProgress?: (progress: number, status?: string) => void, signal?: AbortSignal): Promise<{ lastSync: number }> {
if (onProgress) {
onProgress(10, 'Starting download...');
}
const response = await fetch('/api/documents', { signal });
if (!response.ok) {
throw new Error('Failed to fetch documents from server');
}
if (onProgress) {
onProgress(30, 'Download complete');
}
const { documents } = await response.json();
if (onProgress) {
onProgress(40, 'Parsing documents...');
}
// Process each document
for (const doc of documents) {
for (let i = 0; i < documents.length; i++) {
const doc = documents[i];
// Convert the numeric array back to ArrayBuffer
const uint8Array = new Uint8Array(doc.data);
const documentData = {
@ -700,6 +732,15 @@ class IndexedDBService {
} else {
console.warn(`Unknown document type: ${doc.type}`);
}
if (onProgress) {
// Progress from 40% to 90% for document processing
onProgress(40 + ((i + 1) / documents.length) * 50, `Processing document ${i + 1}/${documents.length}...`);
}
}
if (onProgress) {
onProgress(100, 'Load complete!');
}
return { lastSync: Date.now() };