feat: MVP-4 Sprint 3 - Version history
- Add NoteVersion model for storing note snapshots - Add versions API (list, create, get, restore) - Add version history UI dialog in note detail page - Create version snapshot before each note update
This commit is contained in:
158
src/components/version-history.tsx
Normal file
158
src/components/version-history.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { History, RotateCcw } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface Version {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface VersionHistoryProps {
|
||||
noteId: string
|
||||
}
|
||||
|
||||
export function VersionHistory({ noteId }: VersionHistoryProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [versions, setVersions] = useState<Version[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [restoring, setRestoring] = useState<string | null>(null)
|
||||
const [confirmRestore, setConfirmRestore] = useState<string | null>(null)
|
||||
|
||||
const fetchVersions = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/versions`)
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setVersions(data.data)
|
||||
}
|
||||
} catch {
|
||||
toast.error('Error al cargar las versiones')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen)
|
||||
if (newOpen && versions.length === 0) {
|
||||
fetchVersions()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (versionId: string) => {
|
||||
setRestoring(versionId)
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/versions/${versionId}`, {
|
||||
method: 'PUT',
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
toast.success('Versión restaurada correctamente')
|
||||
setOpen(false)
|
||||
window.location.reload()
|
||||
} else {
|
||||
toast.error(data.error || 'Error al restaurar la versión')
|
||||
}
|
||||
} catch {
|
||||
toast.error('Error al restaurar la versión')
|
||||
} finally {
|
||||
setRestoring(null)
|
||||
setConfirmRestore(null)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<History className="h-4 w-4 mr-1" /> Historial
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Historial de versiones</DialogTitle>
|
||||
<DialogDescription>
|
||||
Revisa y restaura versiones anteriores de esta nota
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">Cargando versiones...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">No hay versiones guardadas</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{versions.map((version) => (
|
||||
<div
|
||||
key={version.id}
|
||||
className="flex items-start justify-between gap-4 p-3 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm mb-1">
|
||||
{formatDate(version.createdAt)}
|
||||
</div>
|
||||
<div className="text-gray-600 text-sm truncate">
|
||||
{version.title}
|
||||
</div>
|
||||
<div className="text-gray-400 text-xs mt-1 truncate">
|
||||
{version.content.substring(0, 50)}...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{confirmRestore === version.id ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-xs text-gray-500">¿Restaurar?</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleRestore(version.id)}
|
||||
disabled={restoring === version.id}
|
||||
>
|
||||
{restoring === version.id ? '...' : 'Sí'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfirmRestore(null)}
|
||||
>
|
||||
No
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmRestore(version.id)}
|
||||
title="Restaurar esta versión"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user