diff --git a/public/app.js b/public/app.js index 4613b52..6813560 100644 --- a/public/app.js +++ b/public/app.js @@ -93,6 +93,13 @@ const elements = { gdriveFolderHint: document.getElementById('gdrive-folder-hint'), gdriveInlineStatus: document.getElementById('gdrive-inline-status'), btnBackupTestGdrive: document.getElementById('btn-backup-test-gdrive'), + btnBackupRestoreCloudModal: document.getElementById('btn-backup-restore-cloud-modal'), + btnSyncCloudBackups: document.getElementById('btn-sync-cloud-backups'), + restoreCloudModal: document.getElementById('restore-cloud-modal'), + cloudRestoreProviderFilter: document.getElementById('cloud-restore-provider-filter'), + btnRefreshCloudModal: document.getElementById('btn-refresh-cloud-modal'), + cloudRestoreModalStatus: document.getElementById('cloud-restore-modal-status'), + cloudRestoreFilesContainer: document.getElementById('cloud-restore-files-container'), adminBackupsHistoryList: document.getElementById('admin-backups-history-list'), // Admin Create User Modal @@ -327,6 +334,18 @@ function registerEvents() { if (elements.btnGdriveRefreshFolders) { elements.btnGdriveRefreshFolders.addEventListener('click', () => loadGoogleDriveFolders(true)); } + if (elements.btnBackupRestoreCloudModal) { + elements.btnBackupRestoreCloudModal.addEventListener('click', openCloudRestoreModal); + } + if (elements.btnSyncCloudBackups) { + elements.btnSyncCloudBackups.addEventListener('click', handleSyncCloudBackups); + } + if (elements.btnRefreshCloudModal) { + elements.btnRefreshCloudModal.addEventListener('click', () => loadLiveCloudBackupsForModal(true)); + } + if (elements.cloudRestoreProviderFilter) { + elements.cloudRestoreProviderFilter.addEventListener('change', () => renderCloudRestoreModalList()); + } } window.addEventListener('message', (event) => { @@ -3302,6 +3321,10 @@ async function fetchAdminBackupConfig() { } renderAdminBackupHistory(data.history || []); + // Automatically scan cloud storage if connected and local history is empty + if ((!data.history || data.history.length === 0) && (cfg.gdriveConnected || cfg.s3Enabled)) { + handleSyncCloudBackups(true); + } } catch (err) { console.error('Error fetching backup config:', err); showBackupStatus(err.message, 'error'); @@ -3323,7 +3346,7 @@ function renderAdminBackupHistory(history) { elements.adminBackupsHistoryList.innerHTML = ` - No cloud backups have been recorded yet. Configure Wasabi S3 or Google Drive above and click "Backup to Cloud Now", or use "Download .DB" / "Download .JSON" for immediate local computer downloads. + No cloud backups found yet. Click "Backup to Cloud Now" to create one, or "Sync from Cloud" / "Restore from Cloud" to scan Google Drive & Wasabi S3. `; @@ -3335,38 +3358,51 @@ function renderAdminBackupHistory(history) { const dateStr = new Date(item.createdAt).toLocaleString(); const triggerBadge = item.triggerType === 'scheduled' ? `Scheduled` - : `Manual`; + : item.triggerType === 'cloud' + ? `Cloud Sync` + : `Manual`; const destBadges = []; - if (item.s3KeyDb) { + if (item.s3KeyDb || item.s3KeyJson) { destBadges.push(`Wasabi S3`); } - if (item.gdriveFileIdDb) { + if (item.gdriveFileIdDb || item.gdriveFileIdJson) { destBadges.push(`Google Drive`); } const statusBadge = item.status === 'success' - ? `✓ Success` + ? `✓ Available` : `⚠ Error`; - const hasCloudFiles = Boolean(item.s3KeyDb || item.gdriveFileIdDb); + const hasDbFile = Boolean(item.s3KeyDb || item.gdriveFileIdDb); + const hasJsonFile = Boolean(item.s3KeyJson || item.gdriveFileIdJson); + const hasCloudFiles = hasDbFile || hasJsonFile; + const countsText = (item.userCount || item.recipeCount || item.shoppingListCount) + ? `${item.userCount} users · ${item.recipeCount} recipes · ${item.shoppingListCount} lists` + : `${item.label || 'Cloud Snapshot'}`; tr.innerHTML = ` ${dateStr} ${triggerBadge} - ${item.userCount} users · ${item.recipeCount} recipes · ${item.shoppingListCount} lists - DB: ${formatBytesHuman(item.dbSizeBytes)}
JSON: ${formatBytesHuman(item.jsonSizeBytes)} + ${countsText} + + ${hasDbFile ? `DB: ${formatBytesHuman(item.dbSizeBytes)}` : ''}${hasDbFile && hasJsonFile ? '
' : ''}${hasJsonFile ? `JSON: ${formatBytesHuman(item.jsonSizeBytes)}` : ''} + ${destBadges.join('') || 'None'} ${statusBadge}${item.errorMessage ? `
${item.errorMessage}
` : ''} - ${hasCloudFiles ? ` - - - ` : ''} @@ -3710,5 +3746,263 @@ async function handleRestoreLocalFile(e) { } } +// ========================================== +// LIVE CLOUD BACKUP BROWSER & RESTORE MODAL +// ========================================== + +let cachedCloudBackups = []; + +function showCloudRestoreModalStatus(message, type = 'info') { + const box = elements.cloudRestoreModalStatus; + if (!box) return; + box.classList.remove('hidden'); + if (type === 'success') { + box.style.background = 'rgba(16, 185, 129, 0.12)'; + box.style.border = '1px solid rgba(16, 185, 129, 0.35)'; + box.style.color = '#10b981'; + } else if (type === 'error') { + box.style.background = 'rgba(239, 68, 68, 0.12)'; + box.style.border = '1px solid rgba(239, 68, 68, 0.35)'; + box.style.color = '#ef4444'; + } else { + box.style.background = 'rgba(59, 130, 246, 0.12)'; + box.style.border = '1px solid rgba(59, 130, 246, 0.35)'; + box.style.color = '#3b82f6'; + } + box.textContent = message; +} + +async function handleSyncCloudBackups(silent = false) { + const btn = elements.btnSyncCloudBackups; + const origHtml = btn ? btn.innerHTML : ''; + if (btn) { + btn.disabled = true; + btn.innerHTML = ` Scanning Cloud...`; + lucide.createIcons(); + } + + try { + const res = await fetch('/api/admin/backups/cloud-files', { + headers: getAuthHeaders() + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to scan cloud backups.'); + + cachedCloudBackups = data.backups || []; + renderAdminBackupHistory(cachedCloudBackups); + if (!silent) { + if (data.providerErrors && data.providerErrors.length > 0) { + showBackupStatus(`Synced ${cachedCloudBackups.length} cloud backup(s) (${data.providerErrors.join('; ')}).`, 'info'); + } else { + showBackupStatus(`Synced ${cachedCloudBackups.length} cloud backup(s) from connected storage.`, 'success'); + } + } + } catch (err) { + if (!silent) showBackupStatus(err.message, 'error'); + } finally { + if (btn) { + btn.disabled = false; + btn.innerHTML = origHtml; + lucide.createIcons(); + } + } +} + +function openCloudRestoreModal() { + if (elements.cloudRestoreModalStatus) { + elements.cloudRestoreModalStatus.classList.add('hidden'); + } + openModal('restore-cloud-modal'); + loadLiveCloudBackupsForModal(false); +} + +async function loadLiveCloudBackupsForModal(showToast = false) { + if (!elements.cloudRestoreFilesContainer) return; + elements.cloudRestoreFilesContainer.innerHTML = ` +
+

Scanning your Google Drive backup folder & Wasabi S3 bucket for available backups...

+
+ `; + + try { + const res = await fetch('/api/admin/backups/cloud-files', { + headers: getAuthHeaders() + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to scan cloud storage.'); + + cachedCloudBackups = data.backups || []; + renderAdminBackupHistory(cachedCloudBackups); + renderCloudRestoreModalList(); + + if (data.providerErrors && data.providerErrors.length > 0) { + showCloudRestoreModalStatus(`Note: ${data.providerErrors.join(' | ')}`, 'error'); + } else if (showToast) { + showCloudRestoreModalStatus(`Found ${cachedCloudBackups.length} backup snapshot(s) in cloud storage.`, 'success'); + } + } catch (err) { + showCloudRestoreModalStatus(err.message, 'error'); + elements.cloudRestoreFilesContainer.innerHTML = ` +
+ Could not load cloud backups. Make sure Google Drive or Wasabi S3 is connected. +
+ `; + } +} + +function renderCloudRestoreModalList() { + if (!elements.cloudRestoreFilesContainer) return; + const filter = elements.cloudRestoreProviderFilter ? elements.cloudRestoreProviderFilter.value : 'all'; + + const filtered = cachedCloudBackups.filter(snap => { + const hasGdrive = Boolean(snap.gdriveFileIdDb || snap.gdriveFileIdJson); + const hasS3 = Boolean(snap.s3KeyDb || snap.s3KeyJson); + if (filter === 'gdrive') return hasGdrive; + if (filter === 's3') return hasS3; + return hasGdrive || hasS3; + }); + + elements.cloudRestoreFilesContainer.innerHTML = ''; + + if (filtered.length === 0) { + elements.cloudRestoreFilesContainer.innerHTML = ` +
+ No cloud backups found for this source. Click "Backup to Cloud Now" to create your first cloud snapshot, or verify your Google Drive destination folder. +
+ `; + return; + } + + filtered.forEach(snap => { + const card = document.createElement('div'); + card.style.cssText = 'background: var(--bg-main); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px;'; + + const dateStr = new Date(snap.createdAt).toLocaleString(); + const badges = []; + if (snap.gdriveFileIdDb || snap.gdriveFileIdJson) { + badges.push(`Google Drive`); + } + if (snap.s3KeyDb || snap.s3KeyJson) { + badges.push(`Wasabi S3`); + } + + const statsLine = (snap.userCount || snap.recipeCount || snap.shoppingListCount) + ? `${snap.userCount} users · ${snap.recipeCount} recipes · ${snap.shoppingListCount} shopping lists` + : snap.label; + + const hasDb = Boolean(snap.gdriveFileIdDb || snap.s3KeyDb); + const hasJson = Boolean(snap.gdriveFileIdJson || snap.s3KeyJson); + + card.innerHTML = ` +
+
+ ${dateStr} + ${badges.join('')} +
+
+ ${statsLine} + ${hasDb ? ` · DB: ${formatBytesHuman(snap.dbSizeBytes)}` : ''} + ${hasJson ? ` · JSON: ${formatBytesHuman(snap.jsonSizeBytes)}` : ''} +
+
+
+ ${hasDb ? ` + + ` : ''} + ${hasJson ? ` + + ` : ''} +
+ `; + + const btnRestoreDb = card.querySelector('.btn-modal-restore-db'); + if (btnRestoreDb) { + btnRestoreDb.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'db', dateStr, btnRestoreDb)); + } + + const btnRestoreJson = card.querySelector('.btn-modal-restore-json'); + if (btnRestoreJson) { + btnRestoreJson.addEventListener('click', () => handleRestoreDirectCloudFile(snap, 'json', dateStr, btnRestoreJson)); + } + + elements.cloudRestoreFilesContainer.appendChild(card); + }); + + lucide.createIcons(); +} + +async function handleRestoreDirectCloudFile(snap, format, dateLabel, btnEl) { + const confirmed = window.confirm( + `Are you sure you want to restore your ForkFolio database from the cloud ${format.toUpperCase()} backup created on ${dateLabel}?\n\nThis will replace current users, recipes, images, and shopping lists with the contents of this cloud backup.` + ); + if (!confirmed) return; + + const origHtml = btnEl ? btnEl.innerHTML : ''; + if (btnEl) { + btnEl.disabled = true; + btnEl.innerHTML = ` Restoring...`; + lucide.createIcons(); + } + + try { + showCloudRestoreModalStatus(`Downloading ${format.toUpperCase()} backup from cloud and restoring database...`, 'info'); + + // Determine provider & identifier + const gdriveId = format === 'json' ? (snap.gdriveFileIdJson || snap.gdriveFileIdDb) : (snap.gdriveFileIdDb || snap.gdriveFileIdJson); + const s3Key = format === 'json' ? (snap.s3KeyJson || snap.s3KeyDb) : (snap.s3KeyDb || snap.s3KeyJson); + const filename = format === 'json' ? snap.filenameJson : snap.filenameDb; + + let res; + if (gdriveId) { + res = await fetch('/api/admin/backups/restore-cloud-file', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ provider: 'gdrive', fileId: gdriveId, filename }) + }); + } else if (s3Key) { + res = await fetch('/api/admin/backups/restore-cloud-file', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ provider: 's3', objectKey: s3Key, filename }) + }); + } else { + res = await fetch(`/api/admin/backups/${snap.id}/restore-cloud`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ format }) + }); + } + + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Cloud restore failed.'); + + const msg = `✓ Database restored from cloud (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`; + showCloudRestoreModalStatus(msg, 'success'); + showBackupStatus(msg, 'success'); + + await fetchRecipes(); + await fetchShoppingLists(); + await fetchAdminBackupConfig(); + + setTimeout(() => { + closeModal('restore-cloud-modal'); + }, 1400); + } catch (err) { + showCloudRestoreModalStatus(`Restore failed: ${err.message}`, 'error'); + showBackupStatus(`Restore failed: ${err.message}`, 'error'); + } finally { + if (btnEl) { + btnEl.disabled = false; + btnEl.innerHTML = origHtml; + lucide.createIcons(); + } + } +} + + diff --git a/public/index.html b/public/index.html index 0343d00..67bd892 100644 --- a/public/index.html +++ b/public/index.html @@ -290,6 +290,9 @@ + @@ -501,7 +504,15 @@
-

Cloud Backup History

+
+
+

Cloud Backup History & Recovery

+

Restore your database directly from any Google Drive or Wasabi S3 backup below, or stream a copy to your computer.

+
+ +
@@ -526,6 +537,43 @@ + + +