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:
136
src/components/backup-list.tsx
Normal file
136
src/components/backup-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
179
src/components/backup-restore-dialog.tsx
Normal file
179
src/components/backup-restore-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user