feat: MVP-5 Sprint 3 - Sidebar, Work Mode, and Drafts

- Enhance note-connections with collapsible sections and recent versions
- Add work mode toggle in header for focused work
- Add draft autosave with 7-day TTL and recovery banner
- Save drafts on changes for new notes
This commit is contained in:
2026-03-22 18:28:14 -03:00
parent cde0a143a5
commit a40ab18b1b
7 changed files with 225 additions and 18 deletions

View File

@@ -0,0 +1,19 @@
'use client'
import { Button } from '@/components/ui/button'
import { AlertCircle } from 'lucide-react'
interface DraftRecoveryBannerProps {
onRestore: () => void
onDiscard: () => void
}
export function DraftRecoveryBanner({ onRestore, onDiscard }: DraftRecoveryBannerProps) {
return (
<div className="bg-yellow-50 border-yellow-200 p-3 rounded-lg flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-yellow-600" />
<p className="text-sm text-yellow-800 flex-1">Se encontró un borrador guardado</p>
<Button size="sm" variant="outline" onClick={onDiscard}>Descartar</Button>
<Button size="sm" onClick={onRestore}>Recuperar</Button>
</div>
)
}

View File

@@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Plus, FileText, Settings, Menu, X } from 'lucide-react'
import { QuickAdd } from '@/components/quick-add'
import { WorkModeToggle } from '@/components/work-mode-toggle'
export function Header() {
const pathname = usePathname()
@@ -43,6 +44,7 @@ export function Header() {
</nav>
<div className="flex items-center gap-2">
<QuickAdd />
<WorkModeToggle />
<Link href="/new">
<Button size="sm" className="gap-1.5">
<Plus className="h-4 w-4" />
@@ -59,6 +61,7 @@ export function Header() {
</Link>
<div className="flex items-center gap-2">
<QuickAdd />
<WorkModeToggle />
<Button
variant="ghost"
size="sm"

View File

@@ -1,9 +1,10 @@
'use client'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ArrowRight, Link2, RefreshCw, ExternalLink, Users } from 'lucide-react'
import { ArrowRight, Link2, RefreshCw, ExternalLink, Users, ChevronDown, ChevronRight, History } from 'lucide-react'
interface BacklinkInfo {
id: string
@@ -38,12 +39,20 @@ function ConnectionGroup({
icon: Icon,
notes,
emptyMessage,
isCollapsed,
onToggle,
}: {
title: string
icon: React.ComponentType<{ className?: string }>
notes: { id: string; title: string; type: string }[]
emptyMessage: string
isCollapsed?: boolean
onToggle?: () => void
}) {
if (notes.length === 0 && isCollapsed) {
return null
}
if (notes.length === 0) {
return (
<div className="space-y-2">
@@ -59,12 +68,19 @@ function ConnectionGroup({
return (
<div className="space-y-2">
<h4 className="text-sm font-medium flex items-center gap-2">
<button
onClick={onToggle}
className="flex items-center gap-2 hover:text-primary transition-colors"
>
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
<Icon className="h-4 w-4" />
{title}
</button>
<Badge variant="secondary" className="ml-auto text-xs">
{notes.length}
</Badge>
</h4>
{!isCollapsed && (
<div className="pl-6 space-y-1">
{notes.map((note) => (
<Link
@@ -76,6 +92,7 @@ function ConnectionGroup({
</Link>
))}
</div>
)}
</div>
)
}
@@ -87,10 +104,24 @@ export function NoteConnections({
relatedNotes,
coUsedNotes,
}: NoteConnectionsProps) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
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 && (
<div className="space-y-2 pt-2 border-t">
<h4 className="text-sm font-medium flex items-center gap-2">
<History className="h-4 w-4" />
Versiones recientes
</h4>
<div className="pl-6 space-y-1">
{recentVersions.map((v) => (
<p key={v.id} className="text-xs text-muted-foreground">
v{v.version} - {new Date(v.createdAt).toLocaleDateString()}
</p>
))}
</div>
</div>
)}
</CardContent>
</Card>
)

View File

@@ -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 (
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
{showDraftBanner && (
<DraftRecoveryBanner onRestore={handleRestoreDraft} onDiscard={handleDiscardDraft} />
)}
<div>
<label className="block text-sm font-medium mb-1">Título</label>
<Input

View File

@@ -0,0 +1,26 @@
'use client'
import { useState, useEffect } from 'react'
import { getWorkMode, setWorkMode } from '@/lib/work-mode'
import { Button } from '@/components/ui/button'
import { Monitor, Eye } from 'lucide-react'
export function WorkModeToggle() {
const [enabled, setEnabled] = useState(false)
useEffect(() => {
setEnabled(getWorkMode())
}, [])
const toggle = () => {
const newValue = !enabled
setEnabled(newValue)
setWorkMode(newValue)
// Could dispatch custom event for other components to listen
}
return (
<Button variant="ghost" size="sm" onClick={toggle} title="Modo trabajo">
{enabled ? <Eye className="h-4 w-4" /> : <Monitor className="h-4 w-4" />}
</Button>
)
}

36
src/lib/drafts.ts Normal file
View File

@@ -0,0 +1,36 @@
const DRAFT_KEY_PREFIX = 'recall_draft_'
const DRAFT_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days
interface Draft {
title: string
content: string
type: string
tags: string[]
savedAt: number
}
export function saveDraft(noteId: string, data: Draft): void {
if (typeof window === 'undefined') return
localStorage.setItem(DRAFT_KEY_PREFIX + noteId, JSON.stringify({ ...data, savedAt: Date.now() }))
}
export function loadDraft(noteId: string): Draft | null {
if (typeof window === 'undefined') return null
const stored = localStorage.getItem(DRAFT_KEY_PREFIX + noteId)
if (!stored) return null
try {
const draft = JSON.parse(stored) as Draft
if (Date.now() - draft.savedAt > DRAFT_TTL) {
deleteDraft(noteId)
return null
}
return draft
} catch {
return null
}
}
export function deleteDraft(noteId: string): void {
if (typeof window === 'undefined') return
localStorage.removeItem(DRAFT_KEY_PREFIX + noteId)
}

11
src/lib/work-mode.ts Normal file
View File

@@ -0,0 +1,11 @@
const WORK_MODE_KEY = 'recall_work_mode'
export function getWorkMode(): boolean {
if (typeof window === 'undefined') return false
return localStorage.getItem(WORK_MODE_KEY) === 'true'
}
export function setWorkMode(enabled: boolean): void {
if (typeof window === 'undefined') return
localStorage.setItem(WORK_MODE_KEY, String(enabled))
}