diff --git a/src/components/draft-recovery-banner.tsx b/src/components/draft-recovery-banner.tsx new file mode 100644 index 0000000..ca175f3 --- /dev/null +++ b/src/components/draft-recovery-banner.tsx @@ -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 ( +
+ +

Se encontró un borrador guardado

+ + +
+ ) +} diff --git a/src/components/header.tsx b/src/components/header.tsx index 5560f8a..648f926 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -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() {
+ {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 (
+ {showDraftBanner && ( + + )}
{ + setEnabled(getWorkMode()) + }, []) + + const toggle = () => { + const newValue = !enabled + setEnabled(newValue) + setWorkMode(newValue) + // Could dispatch custom event for other components to listen + } + + return ( + + ) +} diff --git a/src/lib/drafts.ts b/src/lib/drafts.ts new file mode 100644 index 0000000..e0ce845 --- /dev/null +++ b/src/lib/drafts.ts @@ -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) +} diff --git a/src/lib/work-mode.ts b/src/lib/work-mode.ts new file mode 100644 index 0000000..7b7112a --- /dev/null +++ b/src/lib/work-mode.ts @@ -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)) +}