@@ -59,23 +68,31 @@ function ConnectionGroup({
return (
-
- {title}
+
{notes.length}
-
- {notes.map((note) => (
-
- {note.title}
-
- ))}
-
+ {!isCollapsed && (
+
+ {notes.map((note) => (
+
+ {note.title}
+
+ ))}
+
+ )}
)
}
@@ -87,10 +104,24 @@ export function NoteConnections({
relatedNotes,
coUsedNotes,
}: NoteConnectionsProps) {
+ const [collapsed, setCollapsed] = useState
>({})
+ const [recentVersions, setRecentVersions] = useState<{ id: string; version: number; createdAt: string }[]>([])
+
+ useEffect(() => {
+ fetch(`/api/notes/${noteId}/versions`)
+ .then((r) => r.json())
+ .then((d) => setRecentVersions(d.data?.slice(0, 3) || []))
+ .catch(() => setRecentVersions([]))
+ }, [noteId])
+
const hasAnyConnections =
backlinks.length > 0 || outgoingLinks.length > 0 || relatedNotes.length > 0 || coUsedNotes.length > 0
- if (!hasAnyConnections) {
+ const toggleCollapsed = (key: string) => {
+ setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
+ }
+
+ if (!hasAnyConnections && recentVersions.length === 0) {
return null
}
@@ -113,6 +144,8 @@ export function NoteConnections({
type: bl.sourceNote.type,
}))}
emptyMessage="Ningún otro documento enlaza a esta nota"
+ isCollapsed={collapsed['backlinks']}
+ onToggle={() => toggleCollapsed('backlinks')}
/>
{/* Outgoing links - notes this note links TO */}
@@ -125,6 +158,8 @@ export function NoteConnections({
type: ol.sourceNote.type,
}))}
emptyMessage="Esta nota no enlaza a ningún otro documento"
+ isCollapsed={collapsed['outgoing']}
+ onToggle={() => toggleCollapsed('outgoing')}
/>
{/* Related notes - by content similarity and scoring */}
@@ -137,6 +172,8 @@ export function NoteConnections({
type: rn.type,
}))}
emptyMessage="No hay notas relacionadas"
+ isCollapsed={collapsed['related']}
+ onToggle={() => toggleCollapsed('related')}
/>
{/* Co-used notes - often viewed together */}
@@ -149,7 +186,26 @@ export function NoteConnections({
type: cu.type,
}))}
emptyMessage="No hay notas co-usadas"
+ isCollapsed={collapsed['coused']}
+ onToggle={() => toggleCollapsed('coused')}
/>
+
+ {/* Recent versions */}
+ {recentVersions.length > 0 && (
+
+
+
+ Versiones recientes
+
+
+ {recentVersions.map((v) => (
+
+ v{v.version} - {new Date(v.createdAt).toLocaleDateString()}
+
+ ))}
+
+
+ )}
)
diff --git a/src/components/note-form.tsx b/src/components/note-form.tsx
index d45e324..50100b3 100644
--- a/src/components/note-form.tsx
+++ b/src/components/note-form.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState, useRef, useEffect, useMemo } from 'react'
+import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Note, NoteType, Tag } from '@/types/note'
import { Button } from '@/components/ui/button'
@@ -11,6 +11,8 @@ 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'
+import { saveDraft, loadDraft, deleteDraft } from '@/lib/drafts'
+import { DraftRecoveryBanner } from '@/components/draft-recovery-banner'
// Command fields
interface CommandFields {
@@ -622,6 +624,9 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
const [isPinned, setIsPinned] = useState(initialData?.isPinned || false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isDirty, setIsDirty] = useState(false)
+ const [showDraftBanner, setShowDraftBanner] = useState(false)
+ const [draftLoaded, setDraftLoaded] = useState(false)
+ const draftLoadedRef = useRef(false)
// Store initial values for dirty tracking
const initialValuesRef = useRef({
@@ -650,13 +655,36 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
useUnsavedChanges(isDirty)
+ // Serialized content for drafts and auto-suggest
+ const content = useMemo(() => serializeToMarkdown(type, fields), [type, fields])
+
+ // Load draft on mount (only for new notes)
+ useEffect(() => {
+ if (isEdit || draftLoadedRef.current) return
+ const draftKey = 'new'
+ const draft = loadDraft(draftKey)
+ if (draft) {
+ draftLoadedRef.current = true
+ setDraftLoaded(true)
+ setShowDraftBanner(true)
+ }
+ }, [isEdit])
+
+ // Debounced save draft on changes (only for new notes, after initial load)
+ useEffect(() => {
+ if (isEdit || !draftLoaded || draftLoadedRef.current) return
+ const draftKey = 'new'
+ const timeoutId = setTimeout(() => {
+ saveDraft(draftKey, { title, content, type, tags, savedAt: Date.now() })
+ }, 1000)
+ return () => clearTimeout(timeoutId)
+ }, [title, content, type, tags, isEdit, draftLoaded])
+
const handleTypeChange = (newType: NoteType) => {
setType(newType)
setFields(defaultFields[newType])
}
- const content = useMemo(() => serializeToMarkdown(type, fields), [type, fields])
-
// Auto-suggest tags based on title and content
useEffect(() => {
const fetchSuggestions = async () => {
@@ -762,6 +790,28 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
+ const handleRestoreDraft = useCallback(() => {
+ const draftKey = 'new'
+ const draft = loadDraft(draftKey)
+ if (draft) {
+ setTitle(draft.title)
+ setType(draft.type as NoteType)
+ setFields(parseMarkdownToFields(draft.type as NoteType, draft.content))
+ setTags(draft.tags)
+ setShowDraftBanner(false)
+ draftLoadedRef.current = true
+ setDraftLoaded(true)
+ }
+ }, [])
+
+ const handleDiscardDraft = useCallback(() => {
+ const draftKey = 'new'
+ deleteDraft(draftKey)
+ setShowDraftBanner(false)
+ draftLoadedRef.current = true
+ setDraftLoaded(true)
+ }, [])
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
@@ -787,6 +837,9 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
if (res.ok) {
setIsDirty(false)
+ if (!isEdit) {
+ deleteDraft('new')
+ }
router.push('/notes')
router.refresh()
}
@@ -819,6 +872,9 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
return (