mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
feat: add admin cloud and manual database backup and recovery console
This commit is contained in:
parent
01ed916a51
commit
90373d3cc3
4 changed files with 2113 additions and 17 deletions
558
public/app.js
558
public/app.js
|
|
@ -51,10 +51,44 @@ const elements = {
|
|||
btnAdminAddUser: document.getElementById('btn-admin-add-user'),
|
||||
btnAdminTabUsers: document.getElementById('btn-admin-tab-users'),
|
||||
btnAdminTabShares: document.getElementById('btn-admin-tab-shares'),
|
||||
btnAdminTabBackups: document.getElementById('btn-admin-tab-backups'),
|
||||
adminUsersSection: document.getElementById('admin-users-section'),
|
||||
adminSharesSection: document.getElementById('admin-shares-section'),
|
||||
adminBackupsSection: document.getElementById('admin-backups-section'),
|
||||
adminSharesList: document.getElementById('admin-shares-list'),
|
||||
|
||||
// Admin Backups Console Elements
|
||||
btnBackupRunCloud: document.getElementById('btn-backup-run-cloud'),
|
||||
btnBackupDownloadDb: document.getElementById('btn-backup-download-db'),
|
||||
btnBackupDownloadJson: document.getElementById('btn-backup-download-json'),
|
||||
backupRestoreFileInput: document.getElementById('backup-restore-file-input'),
|
||||
btnBackupRestoreLocal: document.getElementById('btn-backup-restore-local'),
|
||||
backupStatusBanner: document.getElementById('backup-status-banner'),
|
||||
adminBackupConfigForm: document.getElementById('admin-backup-config-form'),
|
||||
backupLastRunBadge: document.getElementById('backup-last-run-badge'),
|
||||
backupScheduleFrequency: document.getElementById('backup-schedule-frequency'),
|
||||
backupScheduleTime: document.getElementById('backup-schedule-time'),
|
||||
backupScheduleDay: document.getElementById('backup-schedule-day'),
|
||||
backupRetentionCount: document.getElementById('backup-retention-count'),
|
||||
backupFormat: document.getElementById('backup-format'),
|
||||
backupEmbedImages: document.getElementById('backup-embed-images'),
|
||||
backupS3Enabled: document.getElementById('backup-s3-enabled'),
|
||||
backupS3Endpoint: document.getElementById('backup-s3-endpoint'),
|
||||
backupS3Region: document.getElementById('backup-s3-region'),
|
||||
backupS3Bucket: document.getElementById('backup-s3-bucket'),
|
||||
backupS3Prefix: document.getElementById('backup-s3-prefix'),
|
||||
backupS3AccessKey: document.getElementById('backup-s3-access-key'),
|
||||
backupS3SecretKey: document.getElementById('backup-s3-secret-key'),
|
||||
btnBackupTestS3: document.getElementById('btn-backup-test-s3'),
|
||||
backupGdriveEnabled: document.getElementById('backup-gdrive-enabled'),
|
||||
backupGdriveClientId: document.getElementById('backup-gdrive-client-id'),
|
||||
backupGdriveClientSecret: document.getElementById('backup-gdrive-client-secret'),
|
||||
backupGdriveRefreshToken: document.getElementById('backup-gdrive-refresh-token'),
|
||||
backupGdriveFolderId: document.getElementById('backup-gdrive-folder-id'),
|
||||
btnBackupAuthorizeGdrive: document.getElementById('btn-backup-authorize-gdrive'),
|
||||
btnBackupTestGdrive: document.getElementById('btn-backup-test-gdrive'),
|
||||
adminBackupsHistoryList: document.getElementById('admin-backups-history-list'),
|
||||
|
||||
// Admin Create User Modal
|
||||
adminCreateUserModal: document.getElementById('admin-create-user-modal'),
|
||||
adminCreateUserForm: document.getElementById('admin-create-user-form'),
|
||||
|
|
@ -260,6 +294,29 @@ function registerEvents() {
|
|||
// Admin Sub-Tab Switchers
|
||||
elements.btnAdminTabUsers.addEventListener('click', () => switchAdminSubTab('users'));
|
||||
elements.btnAdminTabShares.addEventListener('click', () => switchAdminSubTab('shares'));
|
||||
if (elements.btnAdminTabBackups) {
|
||||
elements.btnAdminTabBackups.addEventListener('click', () => switchAdminSubTab('backups'));
|
||||
}
|
||||
|
||||
// Admin Backups Console Actions
|
||||
if (elements.adminBackupConfigForm) {
|
||||
elements.adminBackupConfigForm.addEventListener('submit', handleSaveBackupConfig);
|
||||
elements.btnBackupRunCloud.addEventListener('click', handleRunCloudBackupNow);
|
||||
elements.btnBackupDownloadDb.addEventListener('click', () => handleDownloadLocalBackup('db'));
|
||||
elements.btnBackupDownloadJson.addEventListener('click', () => handleDownloadLocalBackup('json'));
|
||||
elements.btnBackupRestoreLocal.addEventListener('click', () => elements.backupRestoreFileInput.click());
|
||||
elements.backupRestoreFileInput.addEventListener('change', handleRestoreLocalFile);
|
||||
elements.btnBackupTestS3.addEventListener('click', handleTestS3Connection);
|
||||
elements.btnBackupTestGdrive.addEventListener('click', handleTestGdriveConnection);
|
||||
elements.btnBackupAuthorizeGdrive.addEventListener('click', handleAuthorizeGoogleDrive);
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'GDRIVE_OAUTH_SUCCESS') {
|
||||
showBackupStatus('Google Drive account authorized and Refresh Token saved!', 'success');
|
||||
fetchAdminBackupConfig();
|
||||
}
|
||||
});
|
||||
|
||||
// Servings Scaler Buttons
|
||||
elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5));
|
||||
|
|
@ -1530,24 +1587,31 @@ function renderAdminUsers(users) {
|
|||
}
|
||||
|
||||
function switchAdminSubTab(subTabName) {
|
||||
if (subTabName === 'users') {
|
||||
elements.btnAdminTabUsers.classList.add('btn-primary');
|
||||
elements.btnAdminTabUsers.classList.remove('btn-outline');
|
||||
elements.btnAdminTabShares.classList.add('btn-outline');
|
||||
elements.btnAdminTabShares.classList.remove('btn-primary');
|
||||
const tabs = [
|
||||
{ name: 'users', btn: elements.btnAdminTabUsers, section: elements.adminUsersSection },
|
||||
{ name: 'shares', btn: elements.btnAdminTabShares, section: elements.adminSharesSection },
|
||||
{ name: 'backups', btn: elements.btnAdminTabBackups, section: elements.adminBackupsSection }
|
||||
];
|
||||
|
||||
elements.adminUsersSection.style.display = 'block';
|
||||
elements.adminSharesSection.style.display = 'none';
|
||||
tabs.forEach(t => {
|
||||
if (!t.btn || !t.section) return;
|
||||
if (t.name === subTabName) {
|
||||
t.btn.classList.add('btn-primary');
|
||||
t.btn.classList.remove('btn-outline');
|
||||
t.section.style.display = 'block';
|
||||
} else {
|
||||
t.btn.classList.add('btn-outline');
|
||||
t.btn.classList.remove('btn-primary');
|
||||
t.section.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
if (subTabName === 'users') {
|
||||
fetchAdminUsers();
|
||||
} else if (subTabName === 'shares') {
|
||||
elements.btnAdminTabShares.classList.add('btn-primary');
|
||||
elements.btnAdminTabShares.classList.remove('btn-outline');
|
||||
elements.btnAdminTabUsers.classList.add('btn-outline');
|
||||
elements.btnAdminTabUsers.classList.remove('btn-primary');
|
||||
|
||||
elements.adminUsersSection.style.display = 'none';
|
||||
elements.adminSharesSection.style.display = 'block';
|
||||
fetchAdminSharedRecipes();
|
||||
} else if (subTabName === 'backups') {
|
||||
fetchAdminBackupConfig();
|
||||
}
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
|
@ -2919,4 +2983,470 @@ function renderShoppingListsWorkspace() {
|
|||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ADMIN DATABASE BACKUPS & RECOVERY CONSOLE
|
||||
// ==========================================
|
||||
|
||||
let backupStatusTimeout = null;
|
||||
|
||||
function showBackupStatus(message, type = 'info') {
|
||||
if (!elements.backupStatusAlert) return;
|
||||
if (backupStatusTimeout) clearTimeout(backupStatusTimeout);
|
||||
|
||||
elements.backupStatusAlert.className = `backup-status-alert status-${type}`;
|
||||
const icon = type === 'success' ? 'check-circle-2' : type === 'error' ? 'alert-triangle' : 'info';
|
||||
elements.backupStatusAlert.innerHTML = `<i data-lucide="${icon}" style="width:18px;height:18px;flex-shrink:0;"></i><span>${message}</span>`;
|
||||
elements.backupStatusAlert.classList.remove('hidden');
|
||||
lucide.createIcons();
|
||||
|
||||
if (type !== 'error') {
|
||||
backupStatusTimeout = setTimeout(() => {
|
||||
elements.backupStatusAlert.classList.add('hidden');
|
||||
}, 8000);
|
||||
}
|
||||
}
|
||||
|
||||
function getBackupFormPayload() {
|
||||
return {
|
||||
schedule: elements.backupSchedule ? elements.backupSchedule.value : 'off',
|
||||
scheduleTime: elements.backupScheduleTime ? elements.backupScheduleTime.value : '02:00',
|
||||
retentionCount: elements.backupRetentionCount ? Number(elements.backupRetentionCount.value) || 14 : 14,
|
||||
includeRemoteImages: elements.backupIncludeRemoteImages ? elements.backupIncludeRemoteImages.checked : true,
|
||||
s3Enabled: elements.backupS3Enabled ? elements.backupS3Enabled.checked : false,
|
||||
s3Endpoint: elements.backupS3Endpoint ? elements.backupS3Endpoint.value.trim() : 'https://s3.wasabisys.com',
|
||||
s3Region: elements.backupS3Region ? elements.backupS3Region.value.trim() : 'us-east-1',
|
||||
s3Bucket: elements.backupS3Bucket ? elements.backupS3Bucket.value.trim() : '',
|
||||
s3Prefix: elements.backupS3Prefix ? elements.backupS3Prefix.value.trim() : 'forkfolio-backups/',
|
||||
s3AccessKeyId: elements.backupS3AccessKey ? elements.backupS3AccessKey.value.trim() : '',
|
||||
s3SecretAccessKey: elements.backupS3SecretKey ? elements.backupS3SecretKey.value.trim() : '',
|
||||
gdriveEnabled: elements.backupGdriveEnabled ? elements.backupGdriveEnabled.checked : false,
|
||||
gdriveClientId: elements.backupGdriveClientId ? elements.backupGdriveClientId.value.trim() : '',
|
||||
gdriveClientSecret: elements.backupGdriveClientSecret ? elements.backupGdriveClientSecret.value.trim() : '',
|
||||
gdriveRefreshToken: elements.backupGdriveRefreshToken ? elements.backupGdriveRefreshToken.value.trim() : '',
|
||||
gdriveFolderId: elements.backupGdriveFolderId ? elements.backupGdriveFolderId.value.trim() : ''
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAdminBackupConfig() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/backups/config', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (res.status === 401) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error('Failed to load backup configuration.');
|
||||
const data = await res.json();
|
||||
const cfg = data.config || {};
|
||||
|
||||
if (elements.backupSchedule) elements.backupSchedule.value = cfg.schedule || 'off';
|
||||
if (elements.backupScheduleTime) elements.backupScheduleTime.value = cfg.scheduleTime || '02:00';
|
||||
if (elements.backupRetentionCount) elements.backupRetentionCount.value = cfg.retentionCount || 14;
|
||||
if (elements.backupIncludeRemoteImages) elements.backupIncludeRemoteImages.checked = Boolean(cfg.includeRemoteImages);
|
||||
|
||||
if (elements.backupS3Enabled) elements.backupS3Enabled.checked = Boolean(cfg.s3Enabled);
|
||||
if (elements.backupS3Endpoint) elements.backupS3Endpoint.value = cfg.s3Endpoint || 'https://s3.wasabisys.com';
|
||||
if (elements.backupS3Region) elements.backupS3Region.value = cfg.s3Region || 'us-east-1';
|
||||
if (elements.backupS3Bucket) elements.backupS3Bucket.value = cfg.s3Bucket || '';
|
||||
if (elements.backupS3Prefix) elements.backupS3Prefix.value = cfg.s3Prefix || 'forkfolio-backups/';
|
||||
if (elements.backupS3AccessKey) elements.backupS3AccessKey.value = cfg.s3AccessKeyId || '';
|
||||
if (elements.backupS3SecretKey) elements.backupS3SecretKey.value = cfg.s3SecretAccessKey || '';
|
||||
|
||||
if (elements.backupGdriveEnabled) elements.backupGdriveEnabled.checked = Boolean(cfg.gdriveEnabled);
|
||||
if (elements.backupGdriveClientId) elements.backupGdriveClientId.value = cfg.gdriveClientId || '';
|
||||
if (elements.backupGdriveClientSecret) elements.backupGdriveClientSecret.value = cfg.gdriveClientSecret || '';
|
||||
if (elements.backupGdriveRefreshToken) elements.backupGdriveRefreshToken.value = cfg.gdriveRefreshToken || '';
|
||||
if (elements.backupGdriveFolderId) elements.backupGdriveFolderId.value = cfg.gdriveFolderId || '';
|
||||
|
||||
if (elements.backupLastRunBadge) {
|
||||
if (cfg.lastRunAt) {
|
||||
const dt = new Date(cfg.lastRunAt).toLocaleString();
|
||||
const st = cfg.lastRunStatus === 'success' ? '✓ Success' : cfg.lastRunStatus === 'error' ? '⚠ Failed' : cfg.lastRunStatus || '';
|
||||
elements.backupLastRunBadge.textContent = `Last Cloud Run: ${dt} (${st})`;
|
||||
} else {
|
||||
elements.backupLastRunBadge.textContent = 'Last Cloud Run: Never';
|
||||
}
|
||||
}
|
||||
|
||||
renderAdminBackupHistory(data.history || []);
|
||||
} catch (err) {
|
||||
console.error('Error fetching backup config:', err);
|
||||
showBackupStatus(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytesHuman(bytes) {
|
||||
const n = Number(bytes) || 0;
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function renderAdminBackupHistory(history) {
|
||||
if (!elements.adminBackupsHistoryList) return;
|
||||
elements.adminBackupsHistoryList.innerHTML = '';
|
||||
|
||||
if (!history || history.length === 0) {
|
||||
elements.adminBackupsHistoryList.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; color: var(--text-muted); padding: 24px; font-style: italic;">
|
||||
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.
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
history.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
const dateStr = new Date(item.createdAt).toLocaleString();
|
||||
const triggerBadge = item.triggerType === 'scheduled'
|
||||
? `<span class="admin-role-badge" style="background:rgba(99,102,241,0.12);color:#6366f1;padding:3px 8px;border-radius:6px;font-size:0.78rem;">Scheduled</span>`
|
||||
: `<span class="admin-role-badge" style="background:rgba(249,115,22,0.12);color:var(--accent);padding:3px 8px;border-radius:6px;font-size:0.78rem;">Manual</span>`;
|
||||
|
||||
const destBadges = [];
|
||||
if (item.s3KeyDb) {
|
||||
destBadges.push(`<span class="admin-role-badge" style="background:rgba(16,185,129,0.12);color:#10b981;padding:3px 8px;border-radius:6px;font-size:0.76rem;margin-right:4px;">Wasabi S3</span>`);
|
||||
}
|
||||
if (item.gdriveFileIdDb) {
|
||||
destBadges.push(`<span class="admin-role-badge" style="background:rgba(59,130,246,0.12);color:#3b82f6;padding:3px 8px;border-radius:6px;font-size:0.76rem;">Google Drive</span>`);
|
||||
}
|
||||
|
||||
const statusBadge = item.status === 'success'
|
||||
? `<span style="color:#10b981;font-weight:600;font-size:0.84rem;">✓ Success</span>`
|
||||
: `<span style="color:#ef4444;font-weight:600;font-size:0.84rem;" title="${(item.errorMessage || '').replace(/"/g, '"')}">⚠ Error</span>`;
|
||||
|
||||
const hasCloudFiles = Boolean(item.s3KeyDb || item.gdriveFileIdDb);
|
||||
|
||||
tr.innerHTML = `
|
||||
<td style="font-size:0.88rem;color:var(--text-main);white-space:nowrap;">${dateStr}</td>
|
||||
<td>${triggerBadge}</td>
|
||||
<td style="font-size:0.85rem;color:var(--text-muted);">${item.userCount} users · ${item.recipeCount} recipes · ${item.shoppingListCount} lists</td>
|
||||
<td style="font-size:0.85rem;color:var(--text-muted);white-space:nowrap;">DB: ${formatBytesHuman(item.dbSizeBytes)}<br>JSON: ${formatBytesHuman(item.jsonSizeBytes)}</td>
|
||||
<td>${destBadges.join('') || '<span style="color:var(--text-muted);font-size:0.8rem;">None</span>'}</td>
|
||||
<td>${statusBadge}${item.errorMessage ? `<div style="font-size:0.75rem;color:#ef4444;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${item.errorMessage.replace(/"/g, '"')}">${item.errorMessage}</div>` : ''}</td>
|
||||
<td style="text-align:right;white-space:nowrap;">
|
||||
${hasCloudFiles ? `
|
||||
<button type="button" class="btn btn-outline btn-dl-db" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;" title="Stream .db from Cloud to Computer">
|
||||
<i data-lucide="download" style="width:12px;height:12px;"></i> .DB
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline btn-dl-json" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;" title="Stream .json from Cloud to Computer">
|
||||
<i data-lucide="file-json" style="width:12px;height:12px;"></i> .JSON
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline btn-restore-cloud" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;margin-right:4px;border-color:rgba(249,115,22,0.4);color:var(--accent);" title="Restore Database from this Cloud Snapshot">
|
||||
<i data-lucide="rotate-ccw" style="width:12px;height:12px;"></i> Restore
|
||||
</button>
|
||||
` : ''}
|
||||
<button type="button" class="btn btn-danger btn-delete-cloud" style="padding:5px 9px;font-size:0.76rem;border-radius:6px;" title="Delete Snapshot from Cloud & History">
|
||||
<i data-lucide="trash-2" style="width:12px;height:12px;"></i>
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
|
||||
const btnDlDb = tr.querySelector('.btn-dl-db');
|
||||
if (btnDlDb) btnDlDb.addEventListener('click', () => downloadCloudBackupItem(item.id, 'db', item.filenameDb));
|
||||
|
||||
const btnDlJson = tr.querySelector('.btn-dl-json');
|
||||
if (btnDlJson) btnDlJson.addEventListener('click', () => downloadCloudBackupItem(item.id, 'json', item.filenameJson));
|
||||
|
||||
const btnRestore = tr.querySelector('.btn-restore-cloud');
|
||||
if (btnRestore) btnRestore.addEventListener('click', () => restoreCloudBackupItem(item.id, dateStr));
|
||||
|
||||
const btnDelete = tr.querySelector('.btn-delete-cloud');
|
||||
if (btnDelete) btnDelete.addEventListener('click', () => deleteCloudBackupItem(item.id, dateStr));
|
||||
|
||||
elements.adminBackupsHistoryList.appendChild(tr);
|
||||
});
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
async function handleSaveBackupConfig(e) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const payload = getBackupFormPayload();
|
||||
const res = await fetch('/api/admin/backups/config', {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to save backup configuration.');
|
||||
showBackupStatus('Backup & schedule settings saved!', 'success');
|
||||
fetchAdminBackupConfig();
|
||||
} catch (err) {
|
||||
showBackupStatus(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestS3Connection() {
|
||||
const btn = elements.btnBackupTestS3;
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<i data-lucide="loader" style="width:13px;height:13px;"></i> Testing...`;
|
||||
lucide.createIcons();
|
||||
|
||||
try {
|
||||
const payload = getBackupFormPayload();
|
||||
const res = await fetch('/api/admin/backups/test-s3', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Wasabi S3 connection test failed.');
|
||||
showBackupStatus(data.message || 'Wasabi S3 connection verified!', 'success');
|
||||
} catch (err) {
|
||||
showBackupStatus(`Wasabi S3 Error: ${err.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestGdriveConnection() {
|
||||
const btn = elements.btnBackupTestGdrive;
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<i data-lucide="loader" style="width:13px;height:13px;"></i> Testing...`;
|
||||
lucide.createIcons();
|
||||
|
||||
try {
|
||||
const payload = getBackupFormPayload();
|
||||
const res = await fetch('/api/admin/backups/test-gdrive', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Google Drive connection test failed.');
|
||||
if (data.folderId && elements.backupGdriveFolderId && !elements.backupGdriveFolderId.value) {
|
||||
elements.backupGdriveFolderId.value = data.folderId;
|
||||
}
|
||||
showBackupStatus(data.message || 'Google Drive API connection verified!', 'success');
|
||||
} catch (err) {
|
||||
showBackupStatus(`Google Drive Error: ${err.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAuthorizeGoogleDrive() {
|
||||
try {
|
||||
const clientId = elements.backupGdriveClientId ? elements.backupGdriveClientId.value.trim() : '';
|
||||
const clientSecret = elements.backupGdriveClientSecret ? elements.backupGdriveClientSecret.value.trim() : '';
|
||||
if (!clientId || !clientSecret) {
|
||||
showBackupStatus('Please enter your Google OAuth2 Client ID and Client Secret first.', 'error');
|
||||
return;
|
||||
}
|
||||
const res = await fetch('/api/admin/backups/gdrive/auth-url', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
clientId,
|
||||
clientSecret,
|
||||
origin: window.location.origin
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to generate Google OAuth2 URL.');
|
||||
window.open(data.authUrl, 'gdrive_oauth', 'width=560,height=680');
|
||||
} catch (err) {
|
||||
showBackupStatus(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRunCloudBackupNow() {
|
||||
const btn = elements.btnBackupRunCloud;
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<i data-lucide="loader" style="width:15px;height:15px;"></i> Backing up to Cloud...`;
|
||||
lucide.createIcons();
|
||||
|
||||
try {
|
||||
// First save current form config so any newly toggled provider is persisted
|
||||
const payload = getBackupFormPayload();
|
||||
await fetch('/api/admin/backups/config', {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const res = await fetch('/api/admin/backups/run', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Cloud backup failed.');
|
||||
showBackupStatus('Cloud backup completed! Temporary local snapshot was wiped after upload.', 'success');
|
||||
fetchAdminBackupConfig();
|
||||
} catch (err) {
|
||||
showBackupStatus(err.message, 'error');
|
||||
fetchAdminBackupConfig();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownloadLocalBackup(format) {
|
||||
const btn = format === 'json' ? elements.btnBackupDownloadJson : elements.btnBackupDownloadDb;
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = `<i data-lucide="loader" style="width:15px;height:15px;"></i> Preparing...`;
|
||||
lucide.createIcons();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/backups/download-local?format=${encodeURIComponent(format)}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || 'Failed to generate local backup download.');
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const disposition = res.headers.get('Content-Disposition') || '';
|
||||
const match = disposition.match(/filename="?([^"]+)"?/i);
|
||||
const filename = match ? match[1] : `forkfolio-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.${format === 'json' ? 'json' : 'db'}`;
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showBackupStatus(`Downloaded ${filename} to your computer (no copy kept on server).`, 'success');
|
||||
} catch (err) {
|
||||
showBackupStatus(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = origHtml;
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadCloudBackupItem(id, format, fallbackName) {
|
||||
try {
|
||||
showBackupStatus(`Streaming ${format.toUpperCase()} snapshot from cloud storage...`, 'info');
|
||||
const res = await fetch(`/api/admin/backups/${id}/download-cloud?format=${encodeURIComponent(format)}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
throw new Error(errData.error || 'Failed to download backup from cloud.');
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const filename = fallbackName || `forkfolio-cloud-backup-${id}.${format}`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
showBackupStatus(`Downloaded ${filename} from cloud storage.`, 'success');
|
||||
} catch (err) {
|
||||
showBackupStatus(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreCloudBackupItem(id, dateLabel) {
|
||||
const confirmed = window.confirm(
|
||||
`Are you sure you want to restore the entire ForkFolio database from the cloud backup taken on ${dateLabel}?\n\nThis will overwrite current users, recipes, and shopping lists with the snapshot.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
showBackupStatus('Downloading and restoring database from cloud snapshot...', 'info');
|
||||
const res = await fetch(`/api/admin/backups/${id}/restore-cloud`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Cloud restore failed.');
|
||||
showBackupStatus(
|
||||
`Database restored (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`,
|
||||
'success'
|
||||
);
|
||||
await fetchRecipes();
|
||||
await fetchShoppingLists();
|
||||
fetchAdminBackupConfig();
|
||||
} catch (err) {
|
||||
showBackupStatus(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCloudBackupItem(id, dateLabel) {
|
||||
const confirmed = window.confirm(`Delete the cloud backup from ${dateLabel} from remote storage and history?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/backups/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to delete backup.');
|
||||
showBackupStatus('Cloud backup deleted.', 'success');
|
||||
fetchAdminBackupConfig();
|
||||
} catch (err) {
|
||||
showBackupStatus(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestoreLocalFile(e) {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`Are you sure you want to restore the entire ForkFolio database from "${file.name}"?\n\nThis will replace current users, recipes, images, and shopping lists with the contents of this backup file.`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
showBackupStatus(`Uploading and restoring "${file.name}"...`, 'info');
|
||||
const base64Data = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result || '');
|
||||
const commaIdx = result.indexOf(',');
|
||||
resolve(commaIdx >= 0 ? result.slice(commaIdx + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Failed to read selected file.'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const res = await fetch('/api/admin/backups/restore-upload', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
filename: file.name,
|
||||
dataBase64: base64Data
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to restore from uploaded file.');
|
||||
showBackupStatus(
|
||||
`Restored from "${file.name}" (${data.restored.userCount} users, ${data.restored.recipeCount} recipes, ${data.restored.shoppingListCount} shopping lists)!`,
|
||||
'success'
|
||||
);
|
||||
await fetchRecipes();
|
||||
await fetchShoppingLists();
|
||||
fetchAdminBackupConfig();
|
||||
} catch (err) {
|
||||
showBackupStatus(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -210,13 +210,16 @@
|
|||
<!-- User Admin Management Console -->
|
||||
<div id="user-admin-panel" style="display: none;" class="admin-panel-container">
|
||||
<!-- Admin Console Sub-Tabs -->
|
||||
<div class="admin-tabs" style="display: flex; gap: 12px; margin-bottom: 24px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px;">
|
||||
<div class="admin-tabs" style="display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 24px; border-bottom: 1px solid var(--border-color); padding-bottom: 12px;">
|
||||
<button class="btn btn-primary" id="btn-admin-tab-users" style="padding: 8px 16px; border-radius: 6px; font-size: 0.9rem; display: flex; align-items: center; gap: 6px;">
|
||||
<i data-lucide="users" style="width: 16px; height: 16px;"></i> <span>Chef Accounts</span>
|
||||
</button>
|
||||
<button class="btn btn-outline" id="btn-admin-tab-shares" style="padding: 8px 16px; border-radius: 6px; font-size: 0.9rem; display: flex; align-items: center; gap: 6px;">
|
||||
<i data-lucide="globe" style="width: 16px; height: 16px;"></i> <span>Public Shared Links</span>
|
||||
</button>
|
||||
<button class="btn btn-outline" id="btn-admin-tab-backups" style="padding: 8px 16px; border-radius: 6px; font-size: 0.9rem; display: flex; align-items: center; gap: 6px;">
|
||||
<i data-lucide="hard-drive" style="width: 16px; height: 16px;"></i> <span>Database Backups</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Section 1: Chef Accounts Management -->
|
||||
|
|
@ -272,6 +275,238 @@
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Cloud & Manual Database Backups -->
|
||||
<div id="admin-backups-section" style="display: none;">
|
||||
<div class="admin-panel-header backup-top-header">
|
||||
<div>
|
||||
<h3 style="font-size: 1.4rem; color: var(--text-main); margin-bottom: 4px;">Database Backup & Recovery Console</h3>
|
||||
<p style="font-size: 0.85rem; color: var(--text-muted);">
|
||||
Back up users, recipes, embedded dish photos, and shopping lists to Wasabi S3, Google Drive, or directly to your local computer. No backup files are retained on the server disk.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="backup-quick-actions">
|
||||
<button type="button" class="btn btn-primary" id="btn-backup-run-cloud" title="Run immediate cloud backup to enabled providers">
|
||||
<i data-lucide="cloud-upload"></i> <span>Backup to Cloud Now</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline" id="btn-backup-download-db" title="Download SQLite .db snapshot to your computer">
|
||||
<i data-lucide="download"></i> <span>Download .DB</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline" id="btn-backup-download-json" title="Download full JSON archive with embedded images to your computer">
|
||||
<i data-lucide="file-json"></i> <span>Download .JSON</span>
|
||||
</button>
|
||||
<input type="file" id="backup-restore-file-input" accept=".db,.sqlite,.json" style="display: none;">
|
||||
<button type="button" class="btn btn-outline" id="btn-backup-restore-local" title="Restore database from a .db or .json file on your computer">
|
||||
<i data-lucide="upload"></i> <span>Restore from File</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="backup-status-banner" class="backup-status-alert" style="display: none;"></div>
|
||||
|
||||
<form id="admin-backup-config-form" class="backup-config-grid">
|
||||
<!-- Card 1: Automated Schedule & Archive Options -->
|
||||
<div class="backup-config-card">
|
||||
<div class="backup-card-header">
|
||||
<div class="backup-card-title">
|
||||
<i data-lucide="calendar-clock"></i>
|
||||
<h4>Automated Schedule & Format</h4>
|
||||
</div>
|
||||
<span id="backup-last-run-badge" class="shopping-stats-pill" style="display: none;">Never run</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="backup-schedule-frequency">Cloud Backup Schedule</label>
|
||||
<select id="backup-schedule-frequency" class="shopping-select" style="width: 100%;">
|
||||
<option value="off">Manual Backups Only (Schedule Off)</option>
|
||||
<option value="6h">Every 6 Hours</option>
|
||||
<option value="12h">Every 12 Hours</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-2" id="backup-time-row">
|
||||
<div class="form-group">
|
||||
<label for="backup-schedule-time">Time of Day (24h)</label>
|
||||
<input type="time" id="backup-schedule-time" value="02:00" class="shopping-select" style="width: 100%;">
|
||||
</div>
|
||||
<div class="form-group" id="backup-day-group">
|
||||
<label for="backup-schedule-day">Day of Week (Weekly)</label>
|
||||
<select id="backup-schedule-day" class="shopping-select" style="width: 100%;">
|
||||
<option value="0">Sunday</option>
|
||||
<option value="1">Monday</option>
|
||||
<option value="2">Tuesday</option>
|
||||
<option value="3">Wednesday</option>
|
||||
<option value="4">Thursday</option>
|
||||
<option value="5">Friday</option>
|
||||
<option value="6">Saturday</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="backup-retention-count">Cloud Retention (Keep Last N)</label>
|
||||
<select id="backup-retention-count" class="shopping-select" style="width: 100%;">
|
||||
<option value="7">Keep Last 7 Backups</option>
|
||||
<option value="14" selected>Keep Last 14 Backups</option>
|
||||
<option value="30">Keep Last 30 Backups</option>
|
||||
<option value="60">Keep Last 60 Backups</option>
|
||||
<option value="0">Unlimited</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="backup-format">Cloud Backup Format</label>
|
||||
<select id="backup-format" class="shopping-select" style="width: 100%;">
|
||||
<option value="both">Both (.DB Snapshot + .JSON Archive)</option>
|
||||
<option value="db">SQLite .DB Snapshot Only</option>
|
||||
<option value="json">JSON Archive Only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 8px;">
|
||||
<label class="checkbox-container" style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" id="backup-embed-images" checked style="cursor: pointer;">
|
||||
<span class="checkbox-label" style="font-size: 0.88rem; color: var(--text-main);">
|
||||
Fetch & embed external recipe images into JSON backup archives
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Wasabi / S3 Connection -->
|
||||
<div class="backup-config-card">
|
||||
<div class="backup-card-header">
|
||||
<div class="backup-card-title">
|
||||
<i data-lucide="database"></i>
|
||||
<h4>Wasabi / S3 Connection</h4>
|
||||
</div>
|
||||
<label class="shopping-consolidate-toggle">
|
||||
<input type="checkbox" id="backup-s3-enabled">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-label">Enable S3</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="backup-s3-endpoint">S3 Endpoint URL</label>
|
||||
<input type="text" id="backup-s3-endpoint" placeholder="https://s3.us-east-1.wasabisys.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="backup-s3-region">Region</label>
|
||||
<input type="text" id="backup-s3-region" placeholder="us-east-1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="backup-s3-bucket">Bucket Name</label>
|
||||
<input type="text" id="backup-s3-bucket" placeholder="my-forkfolio-backups">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="backup-s3-prefix">Folder Prefix</label>
|
||||
<input type="text" id="backup-s3-prefix" placeholder="forkfolio-backups/">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="backup-s3-access-key">Access Key ID</label>
|
||||
<input type="text" id="backup-s3-access-key" placeholder="Wasabi Access Key" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="backup-s3-secret-key">Secret Access Key</label>
|
||||
<input type="password" id="backup-s3-secret-key" placeholder="Wasabi Secret Key" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 6px;">
|
||||
<button type="button" class="btn btn-outline btn-sm" id="btn-backup-test-s3">
|
||||
<i data-lucide="plug-zap"></i> <span>Test S3 Connection</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 3: Google Drive Connection -->
|
||||
<div class="backup-config-card">
|
||||
<div class="backup-card-header">
|
||||
<div class="backup-card-title">
|
||||
<i data-lucide="cloud"></i>
|
||||
<h4>Google Drive API Connection</h4>
|
||||
</div>
|
||||
<label class="shopping-consolidate-toggle">
|
||||
<input type="checkbox" id="backup-gdrive-enabled">
|
||||
<span class="toggle-track"><span class="toggle-thumb"></span></span>
|
||||
<span class="toggle-label">Enable Drive</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="backup-gdrive-client-id">OAuth2 Client ID</label>
|
||||
<input type="text" id="backup-gdrive-client-id" placeholder="...apps.googleusercontent.com" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="backup-gdrive-client-secret">OAuth2 Client Secret</label>
|
||||
<input type="password" id="backup-gdrive-client-secret" placeholder="GOCSPX-..." autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="backup-gdrive-refresh-token">OAuth2 Refresh Token</label>
|
||||
<input type="password" id="backup-gdrive-refresh-token" placeholder="1//04... (or click Authorize below)" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="backup-gdrive-folder-id">Target Drive Folder ID (Optional)</label>
|
||||
<input type="text" id="backup-gdrive-folder-id" placeholder="Auto-creates 'ForkFolio Backups' if blank">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 6px;">
|
||||
<button type="button" class="btn btn-outline btn-sm" id="btn-backup-authorize-gdrive" title="Open Google OAuth consent screen to connect your Google Drive account">
|
||||
<i data-lucide="key-round"></i> <span>Authorize with Google</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline btn-sm" id="btn-backup-test-gdrive">
|
||||
<i data-lucide="plug-zap"></i> <span>Test Drive Connection</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backup-save-bar">
|
||||
<button type="submit" class="btn btn-primary" id="btn-backup-save-config">
|
||||
<i data-lucide="save"></i> <span>Save Backup Configuration & Schedule</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Cloud Backup History Table -->
|
||||
<div style="margin-top: 32px;">
|
||||
<h4 style="font-size: 1.15rem; color: var(--text-main); margin-bottom: 12px;">Cloud Backup History</h4>
|
||||
<div class="table-responsive">
|
||||
<table class="admin-users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Backup Date</th>
|
||||
<th>Trigger</th>
|
||||
<th>Contents</th>
|
||||
<th>Size</th>
|
||||
<th>Cloud Destinations</th>
|
||||
<th>Status</th>
|
||||
<th style="text-align: right;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-backups-history-list">
|
||||
<!-- Populated via JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
|
|
|||
117
public/style.css
117
public/style.css
|
|
@ -2827,3 +2827,120 @@ h1, h2, h3, h4, h5, h6 {
|
|||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
ADMIN BACKUP & RECOVERY CONSOLE STYLES
|
||||
============================================================= */
|
||||
|
||||
.backup-top-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.backup-quick-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.backup-status-alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--border-radius-md);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.backup-status-alert.success {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
border-color: rgba(16, 185, 129, 0.35);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.backup-status-alert.error {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.backup-status-alert.info {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: rgba(99, 102, 241, 0.35);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.backup-config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: 20px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.backup-config-card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.backup-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.backup-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.backup-card-title i,
|
||||
.backup-card-title svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.backup-card-title h4 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.backup-save-bar {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.backup-config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.backup-quick-actions {
|
||||
width: 100%;
|
||||
}
|
||||
.backup-quick-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue