feat: MVP-5 Sprint 1 - Backup/Restore system

- Add backup types and RecallBackup format
- Create backup snapshot engine (createBackupSnapshot)
- Add IndexedDB storage for local backups
- Implement retention policy (max 10, 30-day cleanup)
- Add backup validation and restore logic (merge/replace modes)
- Add backup restore UI dialog with preview and confirmation
- Add unsaved changes guard hook
- Integrate backups section in Settings
- Add backup endpoint to export-import API
This commit is contained in:
2026-03-22 18:16:36 -03:00
parent 544decf4ac
commit 8c80a12b81
14 changed files with 1824 additions and 5 deletions

View File

@@ -0,0 +1,136 @@
'use client'
import { useState, useEffect } from 'react'
import { getBackups, deleteBackup } from '@/lib/backup-storage'
import { RecallBackup } from '@/types/backup'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { toast } from 'sonner'
import { Trash2, RotateCcw, Calendar, FileText } from 'lucide-react'
import { BackupRestoreDialog } from './backup-restore-dialog'
export function BackupList() {
const [backups, setBackups] = useState<RecallBackup[]>([])
const [loading, setLoading] = useState(true)
const [deletingId, setDeletingId] = useState<string | null>(null)
useEffect(() => {
loadBackups()
}, [])
async function loadBackups() {
try {
const data = await getBackups()
setBackups(data)
} catch {
toast.error('Error al cargar los backups')
} finally {
setLoading(false)
}
}
async function handleDelete(id: string) {
setDeletingId(id)
try {
await deleteBackup(id)
setBackups((prev) => prev.filter((b) => b.id !== id))
toast.success('Backup eliminado')
} catch {
toast.error('Error al eliminar el backup')
} finally {
setDeletingId(null)
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('es-ES', {
dateStyle: 'medium',
timeStyle: 'short',
})
}
function getSourceBadgeVariant(source: RecallBackup['source']) {
switch (source) {
case 'automatic':
return 'secondary'
case 'manual':
return 'default'
case 'pre-destructive':
return 'destructive'
default:
return 'secondary'
}
}
function getSourceLabel(source: RecallBackup['source']) {
switch (source) {
case 'automatic':
return 'Automático'
case 'manual':
return 'Manual'
case 'pre-destructive':
return 'Pre-destrucción'
default:
return source
}
}
if (loading) {
return <div className="text-sm text-muted-foreground">Cargando backups...</div>
}
if (backups.length === 0) {
return (
<div className="text-sm text-muted-foreground">
No hay backups disponibles. Los backups se crean automáticamente antes de operaciones
destructivas.
</div>
)
}
return (
<div className="space-y-3">
{backups.map((backup) => (
<div
key={backup.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{formatDate(backup.createdAt)}</span>
<Badge variant={getSourceBadgeVariant(backup.source)}>{getSourceLabel(backup.source)}</Badge>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<FileText className="h-3 w-3" />
{backup.metadata.noteCount} nota{backup.metadata.noteCount !== 1 ? 's' : ''}
</span>
<span>
{backup.metadata.tagCount} tag{backup.metadata.tagCount !== 1 ? 's' : ''}
</span>
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<BackupRestoreDialog
backup={backup}
trigger={
<Button variant="outline" size="sm" className="gap-1 cursor-pointer">
<RotateCcw className="h-3 w-3" />
Restaurar
</Button>
}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(backup.id)}
disabled={deletingId === backup.id}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { validateBackup } from '@/lib/backup-validator'
import { restoreBackup } from '@/lib/restore'
import { RecallBackup } from '@/types/backup'
import { toast } from 'sonner'
import { RotateCcw, FileText, Tag, Calendar, AlertTriangle } from 'lucide-react'
interface BackupRestoreDialogProps {
backup: RecallBackup
trigger?: React.ReactNode
}
export function BackupRestoreDialog({ backup, trigger }: BackupRestoreDialogProps) {
const [open, setOpen] = useState(false)
const [mode, setMode] = useState<'merge' | 'replace'>('merge')
const [confirming, setConfirming] = useState(false)
const [loading, setLoading] = useState(false)
const validation = validateBackup(backup)
const backupInfo = validation.info
function handleModeChange(newMode: 'merge' | 'replace') {
setMode(newMode)
setConfirming(false)
}
async function handleRestore() {
setLoading(true)
try {
const result = await restoreBackup(backup, mode)
if (result.success) {
toast.success(`${result.restored} nota${result.restored !== 1 ? 's' : ''} restaurada${result.restored !== 1 ? 's' : ''} correctamente`)
setOpen(false)
setConfirming(false)
setMode('merge')
} else {
toast.error(`Error al restaurar: ${result.errors.join(', ')}`)
}
} catch {
toast.error('Error al restaurar el backup')
} finally {
setLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
{trigger && <div onClick={() => setOpen(true)}>{trigger}</div>}
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RotateCcw className="h-5 w-5" />
Restaurar Backup
</DialogTitle>
<DialogDescription>
Recupera notas desde un backup anterior
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Backup Info */}
<div className="space-y-3 p-4 bg-muted rounded-lg">
<div className="flex items-center gap-2 text-sm">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span>{backupInfo?.createdAt ? new Date(backupInfo.createdAt).toLocaleString('es-ES') : 'Fecha desconocida'}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<FileText className="h-4 w-4 text-muted-foreground" />
<span>{backupInfo?.noteCount ?? 0} nota{(backupInfo?.noteCount ?? 0) !== 1 ? 's' : ''}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Tag className="h-4 w-4 text-muted-foreground" />
<span>{backupInfo?.tagCount ?? 0} tag{(backupInfo?.tagCount ?? 0) !== 1 ? 's' : ''}</span>
</div>
<div className="text-xs text-muted-foreground">
Fuente: {backupInfo?.source ?? 'desconocida'}
</div>
</div>
{/* Mode Selection */}
{!confirming && (
<div className="space-y-3">
<p className="text-sm font-medium">Modo de restauración</p>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => handleModeChange('merge')}
className={`p-3 border rounded-lg text-left transition-colors ${
mode === 'merge'
? 'border-primary bg-primary/5'
: 'hover:border-muted-foreground/50'
}`}
>
<div className="font-medium text-sm">Combinar</div>
<div className="text-xs text-muted-foreground">
Añade nuevas notas, actualiza existentes
</div>
</button>
<button
type="button"
onClick={() => handleModeChange('replace')}
className={`p-3 border rounded-lg text-left transition-colors ${
mode === 'replace'
? 'border-primary bg-primary/5'
: 'hover:border-muted-foreground/50'
}`}
>
<div className="font-medium text-sm">Reemplazar</div>
<div className="text-xs text-muted-foreground">
Sustituye todo el contenido actual
</div>
</button>
</div>
</div>
)}
{/* Confirmation */}
{confirming && (
<div className="space-y-4">
{mode === 'replace' && (
<div className="flex items-start gap-3 p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-destructive">Operación destructiva</p>
<p className="text-muted-foreground">
Se eliminará el contenido actual antes de restaurar. Se creará un backup de seguridad automáticamente.
</p>
</div>
</div>
)}
<p className="text-sm">
¿Estás seguro de que quieres restaurar este backup? Esta acción{' '}
{mode === 'merge' ? 'no eliminará' : 'eliminará'} notas existentes.
</p>
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
{!confirming ? (
<>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancelar
</Button>
<Button onClick={() => setConfirming(true)}>
Continuar
</Button>
</>
) : (
<>
<Button variant="outline" onClick={() => setConfirming(false)} disabled={loading}>
Volver
</Button>
<Button
variant={mode === 'replace' ? 'destructive' : 'default'}
onClick={handleRestore}
disabled={loading}
>
{loading ? 'Restaurando...' : 'Confirmar'}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Badge } from '@/components/ui/badge'
import { X, Sparkles } from 'lucide-react'
import { inferNoteType } from '@/lib/type-inference'
import { useUnsavedChanges } from '@/hooks/use-unsaved-changes'
// Command fields
interface CommandFields {
@@ -620,6 +621,34 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
const [isFavorite, setIsFavorite] = useState(initialData?.isFavorite || false)
const [isPinned, setIsPinned] = useState(initialData?.isPinned || false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isDirty, setIsDirty] = useState(false)
// Store initial values for dirty tracking
const initialValuesRef = useRef({
title: initialData?.title || '',
type: initialData?.type || 'note',
fields: initialData?.content
? parseMarkdownToFields(initialData.type, initialData.content)
: defaultFields[initialData?.type || 'note'],
tags: initialData?.tags.map(t => t.tag.name) || [],
isFavorite: initialData?.isFavorite || false,
isPinned: initialData?.isPinned || false,
})
// Track dirty state
useEffect(() => {
const hasChanges =
title !== initialValuesRef.current.title ||
type !== initialValuesRef.current.type ||
JSON.stringify(fields) !== JSON.stringify(initialValuesRef.current.fields) ||
JSON.stringify(tags) !== JSON.stringify(initialValuesRef.current.tags) ||
isFavorite !== initialValuesRef.current.isFavorite ||
isPinned !== initialValuesRef.current.isPinned
setIsDirty(hasChanges)
}, [title, type, fields, tags, isFavorite, isPinned])
useUnsavedChanges(isDirty)
const handleTypeChange = (newType: NoteType) => {
setType(newType)
@@ -757,6 +786,7 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
})
if (res.ok) {
setIsDirty(false)
router.push('/notes')
router.refresh()
}

View File

@@ -81,11 +81,11 @@ export function VersionHistory({ noteId }: VersionHistoryProps) {
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<div onClick={() => handleOpenChange(true)}>
<Button variant="outline" size="sm" className="cursor-pointer">
<History className="h-4 w-4 mr-1" /> Historial
</Button>
</DialogTrigger>
</div>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Historial de versiones</DialogTitle>