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:
Luca
2026-07-02 20:37:35 +02:00
parent f57462f798
commit cc79b3d9ec
2 changed files with 115 additions and 103 deletions
@@ -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>
)} )}