refactor(backup): move .picpeak download to the Dashboard tab
Downloading a portable backup is a "make a backup" action, so it belongs next to "Run Backup Now" on the Dashboard, not under Restore. Split the combined card into PicpeakExportCard (Dashboard) and PicpeakRestoreCard (Restore). The manifest stays bundled inside the .picpeak, so there is no separate manifest-only download for the portable format.
This commit is contained in:
@@ -6,23 +6,23 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Button, Card } from '../common';
|
import { Button, Card } from '../common';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
|
|
||||||
|
// Portable ".picpeak" roundtrip, split across two Backup Manager tabs:
|
||||||
|
// - PicpeakExportCard → Dashboard (making a backup)
|
||||||
|
// - PicpeakRestoreCard → Restore (restoring a backup)
|
||||||
|
// The manifest is bundled inside the .picpeak, so there is no separate
|
||||||
|
// "manifest only" download here.
|
||||||
|
|
||||||
interface RestoreResult {
|
interface RestoreResult {
|
||||||
tables: number;
|
tables: number;
|
||||||
filesRestored: number;
|
filesRestored: number;
|
||||||
usesExternalMedia: boolean;
|
usesExternalMedia: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Portable ".picpeak" roundtrip: download a self-contained backup here, upload
|
// ── Download half (Dashboard) ────────────────────────────────────────────────
|
||||||
// it on another instance to clone this one. Restore is a FULL OVERRIDE (all data
|
export const PicpeakExportCard: React.FC = () => {
|
||||||
// replaced) that keeps only the current account — hence the explicit confirm.
|
|
||||||
export const PicpeakBackupCard: React.FC = () => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [includePhotos, setIncludePhotos] = useState(false);
|
const [includePhotos, setIncludePhotos] = useState(false);
|
||||||
const [downloading, setDownloading] = 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 () => {
|
const handleDownload = async () => {
|
||||||
setDownloading(true);
|
setDownloading(true);
|
||||||
@@ -49,6 +49,55 @@ export const PicpeakBackupCard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
PicpeakExportCard.displayName = 'PicpeakExportCard';
|
||||||
|
|
||||||
|
// ── Restore half (Restore tab) ───────────────────────────────────────────────
|
||||||
|
export const PicpeakRestoreCard: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
const [result, setResult] = useState<RestoreResult | null>(null);
|
||||||
|
|
||||||
const onFilePick = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onFilePick = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const f = e.target.files?.[0];
|
const f = e.target.files?.[0];
|
||||||
if (f) setPendingFile(f);
|
if (f) setPendingFile(f);
|
||||||
@@ -77,54 +126,15 @@ export const PicpeakBackupCard: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
{t('backup.picpeak.title', 'Portable backup (.picpeak)')}
|
{t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
<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.')}
|
{t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')}
|
||||||
</p>
|
</p>
|
||||||
<input ref={fileRef} type="file" accept=".picpeak,application/zip" className="hidden" onChange={onFilePick} />
|
<input ref={fileRef} type="file" accept=".picpeak,application/zip" className="hidden" onChange={onFilePick} />
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="mt-3"
|
className="mt-4"
|
||||||
onClick={() => fileRef.current?.click()}
|
onClick={() => fileRef.current?.click()}
|
||||||
leftIcon={<Upload className="h-4 w-4" />}
|
leftIcon={<Upload className="h-4 w-4" />}
|
||||||
>
|
>
|
||||||
@@ -169,7 +179,6 @@ export const PicpeakBackupCard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Destructive confirmation */}
|
{/* Destructive confirmation */}
|
||||||
{pendingFile && (
|
{pendingFile && (
|
||||||
@@ -207,4 +216,4 @@ export const PicpeakBackupCard: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PicpeakBackupCard.displayName = 'PicpeakBackupCard';
|
PicpeakRestoreCard.displayName = 'PicpeakRestoreCard';
|
||||||
|
|||||||
@@ -23,7 +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 { PicpeakExportCard, PicpeakRestoreCard } 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';
|
||||||
@@ -205,12 +205,15 @@ export const BackupManagement: React.FC = () => {
|
|||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{activeTab === 'dashboard' && (
|
{activeTab === 'dashboard' && (
|
||||||
|
<div className="space-y-6">
|
||||||
<BackupDashboard
|
<BackupDashboard
|
||||||
status={backupStatus}
|
status={backupStatus}
|
||||||
config={backupConfig}
|
config={backupConfig}
|
||||||
onRunBackup={() => manualBackupMutation.mutate()}
|
onRunBackup={() => manualBackupMutation.mutate()}
|
||||||
isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending}
|
isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
<PicpeakExportCard />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'configuration' && (
|
{activeTab === 'configuration' && (
|
||||||
@@ -227,7 +230,7 @@ export const BackupManagement: React.FC = () => {
|
|||||||
|
|
||||||
{activeTab === 'restore' && (
|
{activeTab === 'restore' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PicpeakBackupCard />
|
<PicpeakRestoreCard />
|
||||||
<RestoreWizard onVerifyIntegrity={() => setActiveTab('integrity')} />
|
<RestoreWizard onVerifyIntegrity={() => setActiveTab('integrity')} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user