feat(backup): .picpeak download + upload-restore UI in Backup Manager
Adds a self-contained "Portable backup (.picpeak)" card to the Restore
tab, completing the GUI-only roundtrip:
- Download: optional "include original photos" toggle + a prominent
plaintext-secrets warning, streams the file via a blob download.
- Restore: file picker → destructive confirmation modal ("replaces ALL
data except your current account, cannot be undone") → multipart upload
to /admin/backup/picpeak/import → success summary. If the backup uses
external media, shows a banner to reconfigure the mount, with a docs link.
Kept separate from the legacy RestoreWizard (different format/flow). en+de
strings added; dark-mode variants throughout.
This commit is contained in:
@@ -0,0 +1,210 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { Download, Upload, AlertTriangle, ShieldAlert, ExternalLink, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { Button, Card } from '../common';
|
||||||
|
import { api } from '../../config/api';
|
||||||
|
|
||||||
|
interface RestoreResult {
|
||||||
|
tables: number;
|
||||||
|
filesRestored: number;
|
||||||
|
usesExternalMedia: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portable ".picpeak" roundtrip: download a self-contained backup here, upload
|
||||||
|
// it on another instance to clone this one. Restore is a FULL OVERRIDE (all data
|
||||||
|
// replaced) that keeps only the current account — hence the explicit confirm.
|
||||||
|
export const PicpeakBackupCard: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [includePhotos, setIncludePhotos] = useState(false);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
const [result, setResult] = useState<RestoreResult | null>(null);
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
setDownloading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get('/admin/backup/picpeak/export', {
|
||||||
|
params: { includePhotos },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
const cd = (res.headers['content-disposition'] as string) || '';
|
||||||
|
const match = cd.match(/filename="?([^"]+)"?/);
|
||||||
|
const filename = (match && match[1]) || 'picpeak-backup.picpeak';
|
||||||
|
const url = window.URL.createObjectURL(res.data as Blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (_) {
|
||||||
|
toast.error(t('backup.picpeak.downloadFailed', 'Could not create the backup file.'));
|
||||||
|
} finally {
|
||||||
|
setDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFilePick = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) setPendingFile(f);
|
||||||
|
e.target.value = ''; // let the user re-pick the same file after cancelling
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRestore = async () => {
|
||||||
|
if (!pendingFile) return;
|
||||||
|
setRestoring(true);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('backup', pendingFile);
|
||||||
|
const res = await api.post<RestoreResult>('/admin/backup/picpeak/import', fd);
|
||||||
|
setResult(res.data);
|
||||||
|
setPendingFile(null);
|
||||||
|
toast.success(t('backup.picpeak.restoreDone', 'Backup restored.'));
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e.response?.data?.error || t('backup.picpeak.restoreFailed', 'Restore failed.');
|
||||||
|
toast.error(msg);
|
||||||
|
setPendingFile(null);
|
||||||
|
} finally {
|
||||||
|
setRestoring(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding="lg">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('backup.picpeak.title', 'Portable backup (.picpeak)')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t('backup.picpeak.intro', 'Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Download */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-neutral-300"
|
||||||
|
checked={includePhotos}
|
||||||
|
onChange={(e) => setIncludePhotos(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('backup.picpeak.includePhotos', 'Include original gallery photos (larger file)')}
|
||||||
|
</label>
|
||||||
|
<div className="mt-3 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-900/20">
|
||||||
|
<ShieldAlert className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
|
||||||
|
<p className="text-xs text-amber-800 dark:text-amber-200">
|
||||||
|
{t('backup.picpeak.secretsWarning', 'This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-3"
|
||||||
|
isLoading={downloading}
|
||||||
|
onClick={handleDownload}
|
||||||
|
leftIcon={<Download className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
{t('backup.picpeak.download', 'Download .picpeak')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-6 border-neutral-200 dark:border-neutral-700" />
|
||||||
|
|
||||||
|
{/* Restore */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-neutral-800 dark:text-neutral-200">
|
||||||
|
{t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')}
|
||||||
|
</h4>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')}
|
||||||
|
</p>
|
||||||
|
<input ref={fileRef} type="file" accept=".picpeak,application/zip" className="hidden" onChange={onFilePick} />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="mt-3"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
leftIcon={<Upload className="h-4 w-4" />}
|
||||||
|
>
|
||||||
|
{t('backup.picpeak.chooseFile', 'Choose .picpeak file…')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div className="mt-4 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-900/50 dark:bg-green-900/20">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-green-600 dark:text-green-400" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
|
{t('backup.picpeak.restoreDone', 'Backup restored.')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-green-700 dark:text-green-300">
|
||||||
|
{t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', {
|
||||||
|
tables: result.tables,
|
||||||
|
files: result.filesRestored,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{result.usesExternalMedia && (
|
||||||
|
<p className="mt-2 flex items-start gap-1 text-xs text-amber-800 dark:text-amber-300">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
{t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/PicPeak/picpeak/blob/main/README.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-0.5 underline"
|
||||||
|
>
|
||||||
|
{t('backup.picpeak.externalMediaLink', 'Setup guide')}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button variant="primary" size="sm" className="mt-3" onClick={() => window.location.reload()}>
|
||||||
|
{t('backup.picpeak.reload', 'Reload app')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Destructive confirmation */}
|
||||||
|
{pendingFile && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-neutral-800">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-600 dark:text-red-400" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('backup.picpeak.confirmTitle', 'Restore will delete all current data')}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
|
{t('backup.picpeak.confirmBody', 'This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 truncate text-xs text-neutral-500 dark:text-neutral-400">{pendingFile.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<Button variant="outline" onClick={() => setPendingFile(null)} disabled={restoring}>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
className="!bg-red-600 hover:!bg-red-700"
|
||||||
|
isLoading={restoring}
|
||||||
|
onClick={confirmRestore}
|
||||||
|
>
|
||||||
|
{t('backup.picpeak.confirmRestore', 'Delete & restore')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
PicpeakBackupCard.displayName = 'PicpeakBackupCard';
|
||||||
@@ -1779,7 +1779,27 @@
|
|||||||
"title": "E-Mail-Einstellungen"
|
"title": "E-Mail-Einstellungen"
|
||||||
},
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
"title": "Backup"
|
"title": "Backup",
|
||||||
|
"picpeak": {
|
||||||
|
"title": "Portables Backup (.picpeak)",
|
||||||
|
"intro": "Laden Sie eine einzelne, in sich geschlossene Datei herunter und laden Sie sie auf einer anderen Instanz hoch, um diese zu klonen — komplett im Browser.",
|
||||||
|
"includePhotos": "Original-Galeriefotos einschließen (größere Datei)",
|
||||||
|
"secretsWarning": "Diese Datei enthält Geheimnisse im Klartext (E-Mail-Passwort, Admin-Zugangsdaten, API-Schlüssel). Bewahren Sie sie sicher auf und übertragen Sie sie nur über vertrauenswürdige Kanäle.",
|
||||||
|
"download": ".picpeak herunterladen",
|
||||||
|
"downloadFailed": "Die Backup-Datei konnte nicht erstellt werden.",
|
||||||
|
"restoreTitle": "Aus einer .picpeak wiederherstellen",
|
||||||
|
"restoreIntro": "Laden Sie eine .picpeak von dieser oder einer anderen Instanz hoch. Nur dieselbe Datenbank-Engine.",
|
||||||
|
"chooseFile": ".picpeak-Datei auswählen…",
|
||||||
|
"restoreDone": "Backup wiederhergestellt.",
|
||||||
|
"restoreFailed": "Wiederherstellung fehlgeschlagen.",
|
||||||
|
"restoreSummary": "{{tables}} Tabellen und {{files}} Dateien wiederhergestellt.",
|
||||||
|
"externalMediaNote": "Dieses Backup verweist auf eine externe Medienbibliothek. Stellen Sie sicher, dass das externe Medien-Routing auf dieser Instanz konfiguriert ist.",
|
||||||
|
"externalMediaLink": "Einrichtungsanleitung",
|
||||||
|
"reload": "App neu laden",
|
||||||
|
"confirmTitle": "Die Wiederherstellung löscht alle aktuellen Daten",
|
||||||
|
"confirmBody": "Dies ersetzt ALLE Daten auf dieser Instanz dauerhaft durch das hochgeladene Backup, mit Ausnahme Ihres aktuellen Kontos. Dies kann nicht rückgängig gemacht werden.",
|
||||||
|
"confirmRestore": "Löschen & wiederherstellen"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"branding": {
|
"branding": {
|
||||||
"title": "Branding"
|
"title": "Branding"
|
||||||
|
|||||||
@@ -1335,7 +1335,27 @@
|
|||||||
"title": "Email Settings"
|
"title": "Email Settings"
|
||||||
},
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
"title": "Backup"
|
"title": "Backup",
|
||||||
|
"picpeak": {
|
||||||
|
"title": "Portable backup (.picpeak)",
|
||||||
|
"intro": "Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.",
|
||||||
|
"includePhotos": "Include original gallery photos (larger file)",
|
||||||
|
"secretsWarning": "This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.",
|
||||||
|
"download": "Download .picpeak",
|
||||||
|
"downloadFailed": "Could not create the backup file.",
|
||||||
|
"restoreTitle": "Restore from a .picpeak",
|
||||||
|
"restoreIntro": "Upload a .picpeak taken from this or another instance. Same database engine only.",
|
||||||
|
"chooseFile": "Choose .picpeak file…",
|
||||||
|
"restoreDone": "Backup restored.",
|
||||||
|
"restoreFailed": "Restore failed.",
|
||||||
|
"restoreSummary": "{{tables}} tables and {{files}} files restored.",
|
||||||
|
"externalMediaNote": "This backup references an external-media library. Make sure external-media routing is configured on this instance.",
|
||||||
|
"externalMediaLink": "Setup guide",
|
||||||
|
"reload": "Reload app",
|
||||||
|
"confirmTitle": "Restore will delete all current data",
|
||||||
|
"confirmBody": "This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.",
|
||||||
|
"confirmRestore": "Delete & restore"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"branding": {
|
"branding": {
|
||||||
"title": "Branding"
|
"title": "Branding"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { BackupDashboard } from '../../components/admin/BackupDashboard';
|
|||||||
import { BackupConfiguration } from '../../components/admin/BackupConfiguration';
|
import { BackupConfiguration } from '../../components/admin/BackupConfiguration';
|
||||||
import { BackupHistory } from '../../components/admin/BackupHistory';
|
import { BackupHistory } from '../../components/admin/BackupHistory';
|
||||||
import { RestoreWizard } from '../../components/admin/RestoreWizard';
|
import { RestoreWizard } from '../../components/admin/RestoreWizard';
|
||||||
|
import { PicpeakBackupCard } from '../../components/admin/PicpeakBackupCard';
|
||||||
import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard';
|
import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard';
|
||||||
import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard';
|
import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
@@ -225,7 +226,10 @@ export const BackupManagement: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'restore' && (
|
{activeTab === 'restore' && (
|
||||||
<RestoreWizard onVerifyIntegrity={() => setActiveTab('integrity')} />
|
<div className="space-y-6">
|
||||||
|
<PicpeakBackupCard />
|
||||||
|
<RestoreWizard onVerifyIntegrity={() => setActiveTab('integrity')} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'integrity' && (
|
{activeTab === 'integrity' && (
|
||||||
|
|||||||
Reference in New Issue
Block a user