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,99 +126,59 @@ 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.')} {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} />
<Button
variant="outline"
className="mt-4"
onClick={() => fileRef.current?.click()}
leftIcon={<Upload className="h-4 w-4" />}
>
{t('backup.picpeak.chooseFile', 'Choose .picpeak file…')}
</Button>
{/* Download */} {result && (
<div className="mt-6"> <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">
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300"> <div className="flex items-start gap-2">
<input <CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-green-600 dark:text-green-400" />
type="checkbox" <div className="min-w-0">
className="h-4 w-4 rounded border-neutral-300" <p className="text-sm font-medium text-green-800 dark:text-green-200">
checked={includePhotos} {t('backup.picpeak.restoreDone', 'Backup restored.')}
onChange={(e) => setIncludePhotos(e.target.checked)} </p>
/> <p className="mt-0.5 text-xs text-green-700 dark:text-green-300">
{t('backup.picpeak.includePhotos', 'Include original gallery photos (larger file)')} {t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', {
</label> tables: result.tables,
<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"> files: result.filesRestored,
<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"> </p>
{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.')} {result.usesExternalMedia && (
</p> <p className="mt-2 flex items-start gap-1 text-xs text-amber-800 dark:text-amber-300">
</div> <AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<Button <span>
variant="outline" {t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '}
className="mt-3" <a
isLoading={downloading} href="https://github.com/PicPeak/picpeak/blob/main/README.md"
onClick={handleDownload} target="_blank"
leftIcon={<Download className="h-4 w-4" />} rel="noopener noreferrer"
> className="inline-flex items-center gap-0.5 underline"
{t('backup.picpeak.download', 'Download .picpeak')} >
</Button> {t('backup.picpeak.externalMediaLink', 'Setup guide')}
</div> <ExternalLink className="h-3 w-3" />
</a>
<hr className="my-6 border-neutral-200 dark:border-neutral-700" /> </span>
{/* 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>
<p className="mt-0.5 text-xs text-green-700 dark:text-green-300"> )}
{t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', { <Button variant="primary" size="sm" className="mt-3" onClick={() => window.location.reload()}>
tables: result.tables, {t('backup.picpeak.reload', 'Reload app')}
files: result.filesRestored, </Button>
})}
</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> </div>
)} </div>
</div> )}
{/* Destructive confirmation */} {/* Destructive confirmation */}
{pendingFile && ( {pendingFile && (
@@ -207,4 +216,4 @@ export const PicpeakBackupCard: React.FC = () => {
); );
}; };
PicpeakBackupCard.displayName = 'PicpeakBackupCard'; PicpeakRestoreCard.displayName = 'PicpeakRestoreCard';
+11 -8
View File
@@ -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' && (
<BackupDashboard <div className="space-y-6">
status={backupStatus} <BackupDashboard
config={backupConfig} status={backupStatus}
onRunBackup={() => manualBackupMutation.mutate()} config={backupConfig}
isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending} onRunBackup={() => manualBackupMutation.mutate()}
/> 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>
)} )}