Merge pull request #58 from RobbyV2/feat/SaveLoadProgress
This commit is contained in:
commit
3d1ff23904
4 changed files with 150 additions and 28 deletions
|
|
@ -133,6 +133,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
estimatedTimeRemaining={estimatedTimeRemaining || undefined}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
isProcessing={epub ? isAudioCombining : isPDFAudioCombining}
|
isProcessing={epub ? isAudioCombining : isPDFAudioCombining}
|
||||||
|
cancelText="Cancel and download"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,12 @@ interface ProgressPopupProps {
|
||||||
estimatedTimeRemaining?: string;
|
estimatedTimeRemaining?: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isProcessing: boolean;
|
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 (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
show={isOpen}
|
show={isOpen}
|
||||||
|
|
@ -22,7 +25,7 @@ export function ProgressPopup({ isOpen, progress, estimatedTimeRemaining, onCanc
|
||||||
leaveFrom="opacity-100 translate-y-0"
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
leaveTo="opacity-0 -translate-y-4"
|
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 max-w-sm mx-auto">
|
||||||
<div className="w-full transform rounded-lg bg-offbase p-4 shadow-xl pointer-events-auto">
|
<div className="w-full transform rounded-lg bg-offbase p-4 shadow-xl pointer-events-auto">
|
||||||
<div className="space-y-2 truncate">
|
<div className="space-y-2 truncate">
|
||||||
|
|
@ -33,28 +36,32 @@ export function ProgressPopup({ isOpen, progress, estimatedTimeRemaining, onCanc
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center text-sm text-muted text-xs sm:text-sm">
|
<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">
|
<div className="flex flex-col gap-1">
|
||||||
<span>{Math.round(progress)}% complete</span>
|
{operationType && (
|
||||||
{estimatedTimeRemaining && <div>
|
<span className="text-accent font-semibold text-xs uppercase tracking-wide">
|
||||||
<span>•</span>
|
{operationType === 'sync' ? 'Saving to Server' : 'Loading from Server'}
|
||||||
<span>{` ~${estimatedTimeRemaining}`}</span>
|
</span>
|
||||||
</div>}
|
)}
|
||||||
|
{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>•</span>
|
||||||
|
<span>{` ~${estimatedTimeRemaining}`}</span>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex justify-center rounded-lg px-2.5 py-1
|
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]"
|
||||||
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}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing && (
|
||||||
<div className="w-full h-full flex items-center justify-end">
|
<span className="relative inline-block w-4 h-4">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</span>
|
||||||
) : (
|
|
||||||
'Cancel and download'
|
|
||||||
)}
|
)}
|
||||||
|
<span>{cancelText}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ import { indexedDBService } from '@/utils/indexedDB';
|
||||||
import { useDocuments } from '@/contexts/DocumentContext';
|
import { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import { setItem, getItem } from '@/utils/indexedDB';
|
import { setItem, getItem } from '@/utils/indexedDB';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
|
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
|
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
||||||
import { THEMES } from '@/contexts/ThemeContext';
|
import { THEMES } from '@/contexts/ThemeContext';
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -48,9 +50,14 @@ export function SettingsModal() {
|
||||||
const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions);
|
const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions);
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = 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 selectedTheme = themes.find(t => t.id === theme) || themes[0];
|
||||||
const [showClearLocalConfirm, setShowClearLocalConfirm] = useState(false);
|
const [showClearLocalConfirm, setShowClearLocalConfirm] = useState(false);
|
||||||
const [showClearServerConfirm, setShowClearServerConfirm] = useState(false);
|
const [showClearServerConfirm, setShowClearServerConfirm] = useState(false);
|
||||||
|
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
||||||
|
|
||||||
const ttsModels = useMemo(() => [
|
const ttsModels = useMemo(() => [
|
||||||
{ id: 'tts-1', name: 'TTS-1' },
|
{ id: 'tts-1', name: 'TTS-1' },
|
||||||
|
|
@ -84,25 +91,69 @@ export function SettingsModal() {
|
||||||
}, [apiKey, baseUrl, ttsModel, ttsModels, ttsInstructions, checkFirstVist]);
|
}, [apiKey, baseUrl, ttsModel, ttsModels, ttsInstructions, checkFirstVist]);
|
||||||
|
|
||||||
const handleSync = async () => {
|
const handleSync = async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setAbortController(controller);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSyncing(true);
|
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) {
|
} 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 {
|
} finally {
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
|
setShowProgress(false);
|
||||||
|
setProgress(0);
|
||||||
|
setStatusMessage('');
|
||||||
|
setAbortController(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoad = async () => {
|
const handleLoad = async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setAbortController(controller);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
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()]);
|
await Promise.all([refreshPDFs(), refreshEPUBs()]);
|
||||||
} catch (error) {
|
} 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 {
|
} finally {
|
||||||
setIsLoading(false);
|
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
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||||
disabled:opacity-50"
|
disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Loading...' : 'Load docs from Server'}
|
{isLoading ? `Loading... ${Math.round(progress)}%` : 'Load docs from Server'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
|
|
@ -441,7 +492,7 @@ export function SettingsModal() {
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||||
disabled:opacity-50"
|
disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isSyncing ? 'Saving...' : 'Save local to Server'}
|
{isSyncing ? `Saving... ${Math.round(progress)}%` : 'Save local to Server'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
@ -498,6 +549,28 @@ export function SettingsModal() {
|
||||||
confirmText="Delete"
|
confirmText="Delete"
|
||||||
isDangerous={true}
|
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"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 pdfDocs = await this.getAllDocuments();
|
||||||
const epubDocs = await this.getAllEPUBDocuments();
|
const epubDocs = await this.getAllEPUBDocuments();
|
||||||
|
|
||||||
const documents = [];
|
const documents = [];
|
||||||
|
const totalDocs = pdfDocs.length + epubDocs.length;
|
||||||
|
let processedDocs = 0;
|
||||||
|
|
||||||
// Process PDF documents - convert ArrayBuffer to array for JSON serialization
|
// Process PDF documents - convert ArrayBuffer to array for JSON serialization
|
||||||
for (const doc of pdfDocs) {
|
for (const doc of pdfDocs) {
|
||||||
|
|
@ -648,6 +650,10 @@ class IndexedDBService {
|
||||||
type: 'pdf',
|
type: 'pdf',
|
||||||
data: Array.from(new Uint8Array(doc.data))
|
data: Array.from(new Uint8Array(doc.data))
|
||||||
});
|
});
|
||||||
|
processedDocs++;
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress((processedDocs / totalDocs) * 50, `Processing ${processedDocs}/${totalDocs} documents...`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process EPUB documents
|
// Process EPUB documents
|
||||||
|
|
@ -657,31 +663,57 @@ class IndexedDBService {
|
||||||
type: 'epub',
|
type: 'epub',
|
||||||
data: Array.from(new Uint8Array(doc.data))
|
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', {
|
const response = await fetch('/api/documents', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ documents })
|
body: JSON.stringify({ documents }),
|
||||||
|
signal
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to sync documents to server');
|
throw new Error('Failed to sync documents to server');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(100, 'Upload complete!');
|
||||||
|
}
|
||||||
|
|
||||||
return { lastSync: Date.now() };
|
return { lastSync: Date.now() };
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadFromServer(): Promise<{ lastSync: number }> {
|
async loadFromServer(onProgress?: (progress: number, status?: string) => void, signal?: AbortSignal): Promise<{ lastSync: number }> {
|
||||||
const response = await fetch('/api/documents');
|
if (onProgress) {
|
||||||
|
onProgress(10, 'Starting download...');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/documents', { signal });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch documents from server');
|
throw new Error('Failed to fetch documents from server');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(30, 'Download complete');
|
||||||
|
}
|
||||||
|
|
||||||
const { documents } = await response.json();
|
const { documents } = await response.json();
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(40, 'Parsing documents...');
|
||||||
|
}
|
||||||
|
|
||||||
// Process each document
|
// 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
|
// Convert the numeric array back to ArrayBuffer
|
||||||
const uint8Array = new Uint8Array(doc.data);
|
const uint8Array = new Uint8Array(doc.data);
|
||||||
const documentData = {
|
const documentData = {
|
||||||
|
|
@ -700,6 +732,15 @@ class IndexedDBService {
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Unknown document type: ${doc.type}`);
|
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() };
|
return { lastSync: Date.now() };
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue