Merge branch 'main' into statistics

This commit is contained in:
Stefan Hardegger
2025-10-21 07:58:25 +02:00
18 changed files with 1417 additions and 72 deletions

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;
},