DB Backup and Restore

This commit is contained in:
Stefan Hardegger
2025-07-31 07:12:12 +02:00
parent 57859d7a84
commit 590e2590d6
8 changed files with 568 additions and 2 deletions

View File

@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import AppLayout from '../../components/layout/AppLayout';
import { useTheme } from '../../lib/theme';
import Button from '../../components/ui/Button';
import { storyApi, authorApi } from '../../lib/api';
import { storyApi, authorApi, databaseApi } from '../../lib/api';
type FontFamily = 'serif' | 'sans' | 'mono';
type FontSize = 'small' | 'medium' | 'large' | 'extra-large';
@@ -39,6 +39,15 @@ export default function SettingsPage() {
});
const [authorsSchema, setAuthorsSchema] = useState<any>(null);
const [showSchema, setShowSchema] = useState(false);
const [databaseStatus, setDatabaseStatus] = useState<{
backup: { loading: boolean; message: string; success?: boolean };
restore: { loading: boolean; message: string; success?: boolean };
clear: { loading: boolean; message: string; success?: boolean };
}>({
backup: { loading: false, message: '' },
restore: { loading: false, message: '' },
clear: { loading: false, message: '' }
});
// Load settings from localStorage on mount
useEffect(() => {
@@ -157,6 +166,146 @@ export default function SettingsPage() {
}
};
const handleDatabaseBackup = async () => {
setDatabaseStatus(prev => ({
...prev,
backup: { loading: true, message: 'Creating backup...', success: undefined }
}));
try {
const backupBlob = await databaseApi.backup();
// Create download link
const url = window.URL.createObjectURL(backupBlob);
const link = document.createElement('a');
link.href = url;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
link.download = `storycove_backup_${timestamp}.sql`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
setDatabaseStatus(prev => ({
...prev,
backup: { loading: false, message: 'Backup downloaded successfully', success: true }
}));
} catch (error: any) {
setDatabaseStatus(prev => ({
...prev,
backup: { loading: false, message: error.message || 'Backup failed', success: false }
}));
}
// Clear message after 5 seconds
setTimeout(() => {
setDatabaseStatus(prev => ({
...prev,
backup: { loading: false, message: '', success: undefined }
}));
}, 5000);
};
const handleDatabaseRestore = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Reset the input so the same file can be selected again
event.target.value = '';
if (!file.name.endsWith('.sql')) {
setDatabaseStatus(prev => ({
...prev,
restore: { loading: false, message: 'Please select a .sql file', success: false }
}));
return;
}
const confirmed = window.confirm(
'Are you sure you want to restore the database? This will PERMANENTLY DELETE all current data and replace it with the backup data. This action cannot be undone!'
);
if (!confirmed) return;
setDatabaseStatus(prev => ({
...prev,
restore: { loading: true, message: 'Restoring database...', success: undefined }
}));
try {
const result = await databaseApi.restore(file);
setDatabaseStatus(prev => ({
...prev,
restore: {
loading: false,
message: result.success ? result.message : result.message,
success: result.success
}
}));
} catch (error: any) {
setDatabaseStatus(prev => ({
...prev,
restore: { loading: false, message: error.message || 'Restore failed', success: false }
}));
}
// Clear message after 10 seconds for restore (longer because it's important)
setTimeout(() => {
setDatabaseStatus(prev => ({
...prev,
restore: { loading: false, message: '', success: undefined }
}));
}, 10000);
};
const handleDatabaseClear = async () => {
const confirmed = window.confirm(
'Are you ABSOLUTELY SURE you want to clear the entire database? This will PERMANENTLY DELETE ALL stories, authors, series, tags, and collections. This action cannot be undone!'
);
if (!confirmed) return;
const doubleConfirmed = window.confirm(
'This is your final warning! Clicking OK will DELETE EVERYTHING in your StoryCove database. Are you completely certain you want to proceed?'
);
if (!doubleConfirmed) return;
setDatabaseStatus(prev => ({
...prev,
clear: { loading: true, message: 'Clearing database...', success: undefined }
}));
try {
const result = await databaseApi.clear();
setDatabaseStatus(prev => ({
...prev,
clear: {
loading: false,
message: result.success
? `Database cleared successfully. Deleted ${result.deletedRecords} records.`
: result.message,
success: result.success
}
}));
} catch (error: any) {
setDatabaseStatus(prev => ({
...prev,
clear: { loading: false, message: error.message || 'Clear operation failed', success: false }
}));
}
// Clear message after 10 seconds for clear (longer because it's important)
setTimeout(() => {
setDatabaseStatus(prev => ({
...prev,
clear: { loading: false, message: '', success: undefined }
}));
}, 10000);
};
return (
<AppLayout>
<div className="max-w-2xl mx-auto space-y-8">
@@ -463,6 +612,110 @@ export default function SettingsPage() {
</div>
</div>
{/* Database Management */}
<div className="theme-card theme-shadow rounded-lg p-6">
<h2 className="text-xl font-semibold theme-header mb-4">Database Management</h2>
<p className="theme-text mb-6">
Backup, restore, or clear your StoryCove database. These are powerful tools - use with caution!
</p>
<div className="space-y-6">
{/* Backup Section */}
<div className="border theme-border rounded-lg p-4">
<h3 className="text-lg font-semibold theme-header mb-3">💾 Backup Database</h3>
<p className="text-sm theme-text mb-3">
Download a complete backup of your database as an SQL file. This includes all stories, authors, tags, series, and collections.
</p>
<Button
onClick={handleDatabaseBackup}
disabled={databaseStatus.backup.loading}
loading={databaseStatus.backup.loading}
variant="primary"
className="w-full sm:w-auto"
>
{databaseStatus.backup.loading ? 'Creating Backup...' : 'Download Backup'}
</Button>
{databaseStatus.backup.message && (
<div className={`text-sm p-2 rounded mt-3 ${
databaseStatus.backup.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{databaseStatus.backup.message}
</div>
)}
</div>
{/* Restore Section */}
<div className="border theme-border rounded-lg p-4 border-orange-200 dark:border-orange-800">
<h3 className="text-lg font-semibold theme-header mb-3">📥 Restore Database</h3>
<p className="text-sm theme-text mb-3">
<strong className="text-orange-600 dark:text-orange-400">⚠️ Warning:</strong> This will completely replace your current database with the backup file. All existing data will be permanently deleted.
</p>
<div className="flex items-center gap-3">
<input
type="file"
accept=".sql"
onChange={handleDatabaseRestore}
disabled={databaseStatus.restore.loading}
className="flex-1 text-sm theme-text file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:theme-accent-bg file:text-white hover:file:bg-opacity-90 file:cursor-pointer"
/>
</div>
{databaseStatus.restore.message && (
<div className={`text-sm p-2 rounded mt-3 ${
databaseStatus.restore.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{databaseStatus.restore.message}
</div>
)}
{databaseStatus.restore.loading && (
<div className="text-sm theme-text mt-3 flex items-center gap-2">
<div className="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
Restoring database...
</div>
)}
</div>
{/* Clear Section */}
<div className="border theme-border rounded-lg p-4 border-red-200 dark:border-red-800">
<h3 className="text-lg font-semibold theme-header mb-3">🗑️ Clear Database</h3>
<p className="text-sm theme-text mb-3">
<strong className="text-red-600 dark:text-red-400">⚠️ Danger Zone:</strong> This will permanently delete ALL data from your database. Stories, authors, tags, series, and collections will be completely removed. This action cannot be undone!
</p>
<Button
onClick={handleDatabaseClear}
disabled={databaseStatus.clear.loading}
loading={databaseStatus.clear.loading}
variant="secondary"
className="w-full sm:w-auto bg-red-600 hover:bg-red-700 text-white border-red-600"
>
{databaseStatus.clear.loading ? 'Clearing Database...' : 'Clear All Data'}
</Button>
{databaseStatus.clear.message && (
<div className={`text-sm p-2 rounded mt-3 ${
databaseStatus.clear.success
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
}`}>
{databaseStatus.clear.message}
</div>
)}
</div>
<div className="text-sm theme-text bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
<p className="font-medium mb-1">💡 Best Practices:</p>
<ul className="text-xs space-y-1 ml-4">
<li>• <strong>Always backup</strong> before performing restore or clear operations</li>
<li>• <strong>Test restores</strong> in a development environment when possible</li>
<li>• <strong>Store backups safely</strong> in multiple locations for important data</li>
<li>• <strong>Verify backup files</strong> are complete before relying on them</li>
</ul>
</div>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-4">
<Button

View File

@@ -498,6 +498,30 @@ export const collectionApi = {
},
};
// Database management endpoints
export const databaseApi = {
backup: async (): Promise<Blob> => {
const response = await api.post('/database/backup', {}, {
responseType: 'blob'
});
return response.data;
},
restore: async (file: File): Promise<{ success: boolean; message: string }> => {
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/database/restore', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
},
clear: async (): Promise<{ success: boolean; message: string; deletedRecords?: number }> => {
const response = await api.post('/database/clear');
return response.data;
},
};
// Image utility
export const getImageUrl = (path: string): string => {
if (!path) return '';