Merge branch 'main' into statistics
This commit is contained in:
@@ -33,11 +33,18 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
||||
});
|
||||
|
||||
const [databaseStatus, setDatabaseStatus] = useState<{
|
||||
completeBackup: { loading: boolean; message: string; success?: boolean };
|
||||
completeBackup: {
|
||||
loading: boolean;
|
||||
message: string;
|
||||
success?: boolean;
|
||||
jobId?: string;
|
||||
progress?: number;
|
||||
downloadReady?: boolean;
|
||||
};
|
||||
completeRestore: { loading: boolean; message: string; success?: boolean };
|
||||
completeClear: { loading: boolean; message: string; success?: boolean };
|
||||
}>({
|
||||
completeBackup: { loading: false, message: '' },
|
||||
completeBackup: { loading: false, message: '', progress: 0 },
|
||||
completeRestore: { loading: false, message: '' },
|
||||
completeClear: { loading: false, message: '' }
|
||||
});
|
||||
@@ -73,43 +80,117 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
||||
const handleCompleteBackup = async () => {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: { loading: true, message: 'Creating complete backup...', success: undefined }
|
||||
completeBackup: { loading: true, message: 'Starting backup...', success: undefined, progress: 0, downloadReady: false }
|
||||
}));
|
||||
|
||||
try {
|
||||
const backupBlob = await databaseApi.backupComplete();
|
||||
|
||||
// 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_complete_backup_${timestamp}.zip`;
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
// Start the async backup job
|
||||
const startResponse = await databaseApi.backupComplete();
|
||||
const jobId = startResponse.jobId;
|
||||
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: { loading: false, message: 'Complete backup downloaded successfully', success: true }
|
||||
completeBackup: { ...prev.completeBackup, jobId, message: 'Backup in progress...' }
|
||||
}));
|
||||
|
||||
// Poll for progress
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const status = await databaseApi.getBackupStatus(jobId);
|
||||
|
||||
if (status.status === 'COMPLETED') {
|
||||
clearInterval(pollInterval);
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: {
|
||||
loading: false,
|
||||
message: 'Backup completed! Ready to download.',
|
||||
success: true,
|
||||
jobId,
|
||||
progress: 100,
|
||||
downloadReady: true
|
||||
}
|
||||
}));
|
||||
|
||||
// Clear message after 30 seconds (keep download button visible)
|
||||
setTimeout(() => {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: { ...prev.completeBackup, message: '' }
|
||||
}));
|
||||
}, 30000);
|
||||
} else if (status.status === 'FAILED') {
|
||||
clearInterval(pollInterval);
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: {
|
||||
loading: false,
|
||||
message: `Backup failed: ${status.errorMessage}`,
|
||||
success: false,
|
||||
progress: 0,
|
||||
downloadReady: false
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
// Update progress
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: {
|
||||
...prev.completeBackup,
|
||||
progress: status.progress,
|
||||
message: `Creating backup... ${status.progress}%`
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (pollError: any) {
|
||||
clearInterval(pollInterval);
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: {
|
||||
loading: false,
|
||||
message: `Failed to check backup status: ${pollError.message}`,
|
||||
success: false,
|
||||
progress: 0,
|
||||
downloadReady: false
|
||||
}
|
||||
}));
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
|
||||
} catch (error: any) {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: { loading: false, message: error.message || 'Complete backup failed', success: false }
|
||||
completeBackup: {
|
||||
loading: false,
|
||||
message: error.message || 'Failed to start backup',
|
||||
success: false,
|
||||
progress: 0,
|
||||
downloadReady: false
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Clear message after 5 seconds
|
||||
setTimeout(() => {
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: { loading: false, message: '', success: undefined }
|
||||
}));
|
||||
}, 5000);
|
||||
const handleDownloadBackup = (jobId: string) => {
|
||||
const downloadUrl = databaseApi.downloadBackup(jobId);
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = ''; // Filename will be set by server
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clear the download ready state after download
|
||||
setDatabaseStatus(prev => ({
|
||||
...prev,
|
||||
completeBackup: {
|
||||
loading: false,
|
||||
message: 'Backup downloaded successfully',
|
||||
success: true,
|
||||
progress: 100,
|
||||
downloadReady: false
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCompleteRestore = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -792,20 +873,50 @@ export default function SystemSettings({}: SystemSettingsProps) {
|
||||
<p className="text-sm theme-text mb-3">
|
||||
Download a complete backup as a ZIP file. This includes your database AND all uploaded files (cover images, avatars). This is a comprehensive backup of your entire StoryCove installation.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleCompleteBackup}
|
||||
disabled={databaseStatus.completeBackup.loading}
|
||||
loading={databaseStatus.completeBackup.loading}
|
||||
variant="primary"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{databaseStatus.completeBackup.loading ? 'Creating Backup...' : 'Download Backup'}
|
||||
</Button>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={handleCompleteBackup}
|
||||
disabled={databaseStatus.completeBackup.loading || databaseStatus.completeBackup.downloadReady}
|
||||
loading={databaseStatus.completeBackup.loading}
|
||||
variant="primary"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{databaseStatus.completeBackup.loading ? 'Creating Backup...' : 'Create Backup'}
|
||||
</Button>
|
||||
|
||||
{databaseStatus.completeBackup.downloadReady && databaseStatus.completeBackup.jobId && (
|
||||
<Button
|
||||
onClick={() => handleDownloadBackup(databaseStatus.completeBackup.jobId!)}
|
||||
variant="primary"
|
||||
className="w-full sm:w-auto ml-0 sm:ml-3 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
⬇️ Download Backup
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{databaseStatus.completeBackup.loading && databaseStatus.completeBackup.progress !== undefined && (
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-sm theme-text mb-1">
|
||||
<span>Progress</span>
|
||||
<span>{databaseStatus.completeBackup.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-blue-600 dark:bg-blue-500 h-2.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${databaseStatus.completeBackup.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{databaseStatus.completeBackup.message && (
|
||||
<div className={`text-sm p-2 rounded mt-3 ${
|
||||
databaseStatus.completeBackup.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.completeBackup.success === false
|
||||
? 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200'
|
||||
}`}>
|
||||
{databaseStatus.completeBackup.message}
|
||||
</div>
|
||||
|
||||
@@ -114,9 +114,10 @@ const htmlToSlate = (html: string): Descendant[] => {
|
||||
const img = element as HTMLImageElement;
|
||||
results.push({
|
||||
type: 'image',
|
||||
src: img.src || img.getAttribute('src') || '',
|
||||
alt: img.alt || img.getAttribute('alt') || '',
|
||||
caption: img.title || img.getAttribute('title') || '',
|
||||
// Use getAttribute to preserve relative URLs instead of .src which converts to absolute
|
||||
src: img.getAttribute('src') || '',
|
||||
alt: img.getAttribute('alt') || '',
|
||||
caption: img.getAttribute('title') || '',
|
||||
children: [{ text: '' }] // Images need children in Slate
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -1013,10 +1013,47 @@ export const databaseApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
backupComplete: async (): Promise<Blob> => {
|
||||
const response = await api.post('/database/backup-complete', {}, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
backupComplete: async (): Promise<{ success: boolean; jobId: string; status: string; message: string }> => {
|
||||
const response = await api.post('/database/backup-complete');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getBackupStatus: async (jobId: string): Promise<{
|
||||
success: boolean;
|
||||
jobId: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
fileSizeBytes: number;
|
||||
createdAt: string;
|
||||
completedAt: string;
|
||||
errorMessage: string;
|
||||
}> => {
|
||||
const response = await api.get(`/database/backup-status/${jobId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
downloadBackup: (jobId: string): string => {
|
||||
return `/api/database/backup-download/${jobId}`;
|
||||
},
|
||||
|
||||
listBackups: async (): Promise<{
|
||||
success: boolean;
|
||||
backups: Array<{
|
||||
jobId: string;
|
||||
type: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
fileSizeBytes: number;
|
||||
createdAt: string;
|
||||
completedAt: string;
|
||||
}>;
|
||||
}> => {
|
||||
const response = await api.get('/database/backup-list');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteBackup: async (jobId: string): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await api.delete(`/database/backup/${jobId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user