feat(backup): admin and account UI for server backups, import by section
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m44s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m44s
Admin settings: switch, time, retention, list with download and a "Jetzt sichern" button. Account settings: own server backups with download and restore (replace or add, for chosen sections, replace needs a second tap). The MindSift import gets the same section picker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c97765e08e
commit
64e077dd57
13 files changed
+446
-18
No files matched your search
@@ -0,0 +1,151 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Archive, Check, Download, RotateCcw } from 'lucide-react';
|
||||
import { BackupFile, DataSection, DATA_SECTIONS, ImportSummary } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { formatBytes, saveBlob } from '../../lib/download';
|
||||
import { SectionPicker } from './SectionPicker';
|
||||
|
||||
// The user's own automatic backups on the server: download or restore (all or some sections)
|
||||
export const ServerBackupsCard: React.FC = () => {
|
||||
const [files, setFiles] = useState<BackupFile[]>([]);
|
||||
const [open, setOpen] = useState<string | null>(null); // backup being restored
|
||||
const [mode, setMode] = useState<'merge' | 'restore'>('restore');
|
||||
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<ImportSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.listMyBackups().then(setFiles).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const startRestore = (name: string) => {
|
||||
setOpen(open === name ? null : name);
|
||||
setConfirm(false);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const restore = async () => {
|
||||
if (!open) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setResult(await api.restoreMyBackup(open, mode, sections));
|
||||
setConfirm(false);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Wiederherstellen fehlgeschlagen');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sectionNames = DATA_SECTIONS.filter((s) => sections.includes(s.id))
|
||||
.map((s) => s.label)
|
||||
.join(', ');
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||
<Archive className="w-3.5 h-3.5 text-accent" />
|
||||
Server-Backups
|
||||
</h3>
|
||||
<p className="text-muted text-[11px] mt-0.5">
|
||||
Automatische tägliche Sicherungen deiner Daten auf dem Server (falls die Administration sie eingeschaltet hat).
|
||||
</p>
|
||||
</div>
|
||||
<div className="border border-border divide-y divide-border bg-canvas">
|
||||
{files.length === 0 ? (
|
||||
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
|
||||
) : (
|
||||
files.map((f) => (
|
||||
<div key={f.name} className="p-3 space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-main">{new Date(f.createdAt).toLocaleString('de-DE')}</p>
|
||||
<p className="text-[11px] text-faint">{formatBytes(f.size)}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => saveBlob(await api.downloadMyBackup(f.name), f.name)}
|
||||
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRestore(f.name)}
|
||||
aria-expanded={open === f.name}
|
||||
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||||
Wiederherstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open === f.name && (
|
||||
<div className="p-3 rounded-card border border-border bg-panel space-y-2.5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2" role="radiogroup" aria-label="Art der Wiederherstellung">
|
||||
{(
|
||||
[
|
||||
['restore', 'Ersetzen', 'Gewählte Bereiche vorher leeren.'],
|
||||
['merge', 'Hinzufügen', 'Bestehendes behalten, Backup daneben anlegen.'],
|
||||
] as const
|
||||
).map(([value, label, text]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={mode === value}
|
||||
onClick={() => {
|
||||
setMode(value);
|
||||
setConfirm(false);
|
||||
}}
|
||||
className={`p-2 min-h-[44px] text-left border rounded-card transition-colors ${
|
||||
mode === value ? (value === 'restore' ? 'border-status-err bg-status-err/10' : 'border-accent bg-accent/10') : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-xs font-semibold text-main">{label}</span>
|
||||
<span className="block text-[11px] text-muted">{text}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SectionPicker value={sections} onChange={setSections} />
|
||||
{result ? (
|
||||
<p className="text-[11px] text-status-ok flex items-center gap-1">
|
||||
<Check className="w-3.5 h-3.5" /> Wiederhergestellt: {result.notes} Notizen, {result.tasks} Aufgaben, {result.lists} Listen,{' '}
|
||||
{result.inbox} Gedanken, {result.themes ?? 0} Themes.
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => (mode === 'restore' && !confirm ? setConfirm(true) : restore())}
|
||||
className={`h-11 sm:h-8 px-4 text-xs font-semibold disabled:opacity-50 ${
|
||||
mode === 'restore' ? 'btn-action border-status-err text-status-err' : 'btn-primary'
|
||||
}`}
|
||||
>
|
||||
{busy
|
||||
? 'Stelle wieder her…'
|
||||
: mode === 'restore' && confirm
|
||||
? `Wirklich ${sectionNames} ersetzen?`
|
||||
: mode === 'restore'
|
||||
? 'Ersetzen'
|
||||
: 'Hinzufügen'}
|
||||
</button>
|
||||
)}
|
||||
{error && <p className="text-[11px] text-status-err">{error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user