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:
19
src/components/draft-recovery-banner.tsx
Normal file
19
src/components/draft-recovery-banner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Plus, FileText, Settings, Menu, X } from 'lucide-react'
|
import { Plus, FileText, Settings, Menu, X } from 'lucide-react'
|
||||||
import { QuickAdd } from '@/components/quick-add'
|
import { QuickAdd } from '@/components/quick-add'
|
||||||
|
import { WorkModeToggle } from '@/components/work-mode-toggle'
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@@ -43,6 +44,7 @@ export function Header() {
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<QuickAdd />
|
<QuickAdd />
|
||||||
|
<WorkModeToggle />
|
||||||
<Link href="/new">
|
<Link href="/new">
|
||||||
<Button size="sm" className="gap-1.5">
|
<Button size="sm" className="gap-1.5">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
@@ -59,6 +61,7 @@ export function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<QuickAdd />
|
<QuickAdd />
|
||||||
|
<WorkModeToggle />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 {
|
interface BacklinkInfo {
|
||||||
id: string
|
id: string
|
||||||
@@ -38,12 +39,20 @@ function ConnectionGroup({
|
|||||||
icon: Icon,
|
icon: Icon,
|
||||||
notes,
|
notes,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
|
isCollapsed,
|
||||||
|
onToggle,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
icon: React.ComponentType<{ className?: string }>
|
icon: React.ComponentType<{ className?: string }>
|
||||||
notes: { id: string; title: string; type: string }[]
|
notes: { id: string; title: string; type: string }[]
|
||||||
emptyMessage: string
|
emptyMessage: string
|
||||||
|
isCollapsed?: boolean
|
||||||
|
onToggle?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
if (notes.length === 0 && isCollapsed) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
if (notes.length === 0) {
|
if (notes.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -59,23 +68,31 @@ function ConnectionGroup({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||||
<Icon className="h-4 w-4" />
|
<button
|
||||||
{title}
|
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">
|
<Badge variant="secondary" className="ml-auto text-xs">
|
||||||
{notes.length}
|
{notes.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</h4>
|
</h4>
|
||||||
<div className="pl-6 space-y-1">
|
{!isCollapsed && (
|
||||||
{notes.map((note) => (
|
<div className="pl-6 space-y-1">
|
||||||
<Link
|
{notes.map((note) => (
|
||||||
key={note.id}
|
<Link
|
||||||
href={`/notes/${note.id}`}
|
key={note.id}
|
||||||
className="block text-sm text-foreground hover:text-primary transition-colors"
|
href={`/notes/${note.id}`}
|
||||||
>
|
className="block text-sm text-foreground hover:text-primary transition-colors"
|
||||||
{note.title}
|
>
|
||||||
</Link>
|
{note.title}
|
||||||
))}
|
</Link>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -87,10 +104,24 @@ export function NoteConnections({
|
|||||||
relatedNotes,
|
relatedNotes,
|
||||||
coUsedNotes,
|
coUsedNotes,
|
||||||
}: NoteConnectionsProps) {
|
}: 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 =
|
const hasAnyConnections =
|
||||||
backlinks.length > 0 || outgoingLinks.length > 0 || relatedNotes.length > 0 || coUsedNotes.length > 0
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +144,8 @@ export function NoteConnections({
|
|||||||
type: bl.sourceNote.type,
|
type: bl.sourceNote.type,
|
||||||
}))}
|
}))}
|
||||||
emptyMessage="Ningún otro documento enlaza a esta nota"
|
emptyMessage="Ningún otro documento enlaza a esta nota"
|
||||||
|
isCollapsed={collapsed['backlinks']}
|
||||||
|
onToggle={() => toggleCollapsed('backlinks')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Outgoing links - notes this note links TO */}
|
{/* Outgoing links - notes this note links TO */}
|
||||||
@@ -125,6 +158,8 @@ export function NoteConnections({
|
|||||||
type: ol.sourceNote.type,
|
type: ol.sourceNote.type,
|
||||||
}))}
|
}))}
|
||||||
emptyMessage="Esta nota no enlaza a ningún otro documento"
|
emptyMessage="Esta nota no enlaza a ningún otro documento"
|
||||||
|
isCollapsed={collapsed['outgoing']}
|
||||||
|
onToggle={() => toggleCollapsed('outgoing')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Related notes - by content similarity and scoring */}
|
{/* Related notes - by content similarity and scoring */}
|
||||||
@@ -137,6 +172,8 @@ export function NoteConnections({
|
|||||||
type: rn.type,
|
type: rn.type,
|
||||||
}))}
|
}))}
|
||||||
emptyMessage="No hay notas relacionadas"
|
emptyMessage="No hay notas relacionadas"
|
||||||
|
isCollapsed={collapsed['related']}
|
||||||
|
onToggle={() => toggleCollapsed('related')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Co-used notes - often viewed together */}
|
{/* Co-used notes - often viewed together */}
|
||||||
@@ -149,7 +186,26 @@ export function NoteConnections({
|
|||||||
type: cu.type,
|
type: cu.type,
|
||||||
}))}
|
}))}
|
||||||
emptyMessage="No hay notas co-usadas"
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Note, NoteType, Tag } from '@/types/note'
|
import { Note, NoteType, Tag } from '@/types/note'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -11,6 +11,8 @@ import { Badge } from '@/components/ui/badge'
|
|||||||
import { X, Sparkles } from 'lucide-react'
|
import { X, Sparkles } from 'lucide-react'
|
||||||
import { inferNoteType } from '@/lib/type-inference'
|
import { inferNoteType } from '@/lib/type-inference'
|
||||||
import { useUnsavedChanges } from '@/hooks/use-unsaved-changes'
|
import { useUnsavedChanges } from '@/hooks/use-unsaved-changes'
|
||||||
|
import { saveDraft, loadDraft, deleteDraft } from '@/lib/drafts'
|
||||||
|
import { DraftRecoveryBanner } from '@/components/draft-recovery-banner'
|
||||||
|
|
||||||
// Command fields
|
// Command fields
|
||||||
interface CommandFields {
|
interface CommandFields {
|
||||||
@@ -622,6 +624,9 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
const [isPinned, setIsPinned] = useState(initialData?.isPinned || false)
|
const [isPinned, setIsPinned] = useState(initialData?.isPinned || false)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [isDirty, setIsDirty] = 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
|
// Store initial values for dirty tracking
|
||||||
const initialValuesRef = useRef({
|
const initialValuesRef = useRef({
|
||||||
@@ -650,13 +655,36 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
|
|
||||||
useUnsavedChanges(isDirty)
|
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) => {
|
const handleTypeChange = (newType: NoteType) => {
|
||||||
setType(newType)
|
setType(newType)
|
||||||
setFields(defaultFields[newType])
|
setFields(defaultFields[newType])
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = useMemo(() => serializeToMarkdown(type, fields), [type, fields])
|
|
||||||
|
|
||||||
// Auto-suggest tags based on title and content
|
// Auto-suggest tags based on title and content
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSuggestions = async () => {
|
const fetchSuggestions = async () => {
|
||||||
@@ -762,6 +790,28 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
@@ -787,6 +837,9 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setIsDirty(false)
|
setIsDirty(false)
|
||||||
|
if (!isEdit) {
|
||||||
|
deleteDraft('new')
|
||||||
|
}
|
||||||
router.push('/notes')
|
router.push('/notes')
|
||||||
router.refresh()
|
router.refresh()
|
||||||
}
|
}
|
||||||
@@ -819,6 +872,9 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
|
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
|
||||||
|
{showDraftBanner && (
|
||||||
|
<DraftRecoveryBanner onRestore={handleRestoreDraft} onDiscard={handleDiscardDraft} />
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Título</label>
|
<label className="block text-sm font-medium mb-1">Título</label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
26
src/components/work-mode-toggle.tsx
Normal file
26
src/components/work-mode-toggle.tsx
Normal 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
36
src/lib/drafts.ts
Normal 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
11
src/lib/work-mode.ts
Normal 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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user