'use client'; import { useEffect, useRef, useState, Suspense } from 'react'; import { Plus, FileText, Link2, GitBranch, Trash2 } from 'lucide-react'; import dynamic from 'next/dynamic'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Badge } from '@/components/ui/badge'; import { DailyNoteButton } from '@/components/notes/daily-note-button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; // Lazy load TipTap editor (~80KB TipTap + extensions) const NoteEditor = dynamic( () => import('@/components/notes/note-editor').then((m) => m.NoteEditor), { ssr: false, loading: () => (
Loading editor...
), } ); // Lazy load react-force-graph-2d (~120KB + three.js) const NoteGraph = dynamic( () => import('@/components/notes/note-graph').then((m) => m.NoteGraph), { ssr: false, loading: () => (
Loading graph...
), } ); interface Note { id: string; title: string; content: string; domain: string; created: string; updated: string; } interface Backlink { id: string; title: string; } export default function NotesPage() { const [notes, setNotes] = useState([]); const [selectedNote, setSelectedNote] = useState(null); const [backlinks, setBacklinks] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [backlinksError, setBacklinksError] = useState(null); const [backlinksLoading, setBacklinksLoading] = useState(false); const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved'); const [noteToDelete, setNoteToDelete] = useState(null); const [deleting, setDeleting] = useState(false); const createdFromQuery = useRef(false); const pendingSave = useRef<{ id: string; updates: Partial } | null>(null); const saveTimer = useRef | null>(null); const saveVersion = useRef(0); useEffect(() => { fetchNotes(); return () => { if (saveTimer.current) clearTimeout(saveTimer.current); }; }, []); useEffect(() => { if (new URLSearchParams(window.location.search).get('new') !== 'true' || createdFromQuery.current) return; createdFromQuery.current = true; createNote().finally(() => window.history.replaceState(null, '', '/notes')); // Route-triggered creation should only run once per page visit. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (selectedNote) { fetchBacklinks(selectedNote.id); } }, [selectedNote]); async function fetchNotes() { setLoading(true); setError(null); try { const response = await fetch('/api/notes?sort=-updated'); if (!response.ok) throw new Error('Unable to load notes.'); const data = await response.json(); const notesList = data.items || []; setNotes(notesList); setSelectedNote((current) => current || notesList[0] || null); } catch (error) { console.error('Failed to fetch notes:', error); setError('Unable to load notes. Please try again.'); } finally { setLoading(false); } } async function fetchBacklinks(noteId: string) { setBacklinksLoading(true); setBacklinksError(null); try { const response = await fetch(`/api/notes/${noteId}/backlinks`); if (!response.ok) throw new Error('Unable to load backlinks.'); const data = await response.json(); setBacklinks(data.items || []); } catch (error) { console.error('Failed to fetch backlinks:', error); setBacklinksError('Unable to load backlinks.'); } finally { setBacklinksLoading(false); } } async function createNote() { try { const response = await fetch('/api/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Untitled note', content: '', domain: 'personal', }), }); if (!response.ok) throw new Error('Unable to create note.'); const newNote = await response.json(); setNotes((current) => [newNote, ...current]); setSelectedNote(newNote); toast.success('Note created'); } catch (error) { console.error('Failed to create note:', error); toast.error('Unable to create note'); } } function handleDailyNoteReady(raw: Record) { const note = raw as unknown as Note; // If the note already appears in the list, just select it const exists = notes.find((n) => n.id === note.id); if (exists) { setSelectedNote(exists); return; } // Otherwise prepend it and select setNotes((current) => [note, ...current]); setSelectedNote(note); } function scheduleSave(noteId: string, updates: Partial) { const version = ++saveVersion.current; pendingSave.current = { id: noteId, updates: { ...(pendingSave.current?.id === noteId ? pendingSave.current.updates : {}), ...updates }, }; if (saveTimer.current) clearTimeout(saveTimer.current); setSaveStatus('Saving'); saveTimer.current = setTimeout(() => { const save = pendingSave.current; pendingSave.current = null; if (save) updateNote(save.id, save.updates, version); }, 700); } async function updateNote(noteId: string, updates: Partial, version: number) { try { const response = await fetch(`/api/notes/${noteId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }); if (!response.ok) throw new Error('Unable to save note.'); const updated = await response.json(); setNotes((current) => current.map((note) => (note.id === noteId ? updated : note))); if (saveVersion.current === version) setSaveStatus('Saved'); } catch (error) { console.error('Failed to update note:', error); if (saveVersion.current === version) setSaveStatus('Failed'); toast.error('Unable to save note'); } } async function deleteNote() { if (!noteToDelete) return; if (pendingSave.current?.id === noteToDelete.id && saveTimer.current) { clearTimeout(saveTimer.current); pendingSave.current = null; } ++saveVersion.current; setDeleting(true); try { const response = await fetch(`/api/notes/${noteToDelete.id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Unable to delete note.'); setNotes((current) => current.filter((note) => note.id !== noteToDelete.id)); setSelectedNote((selected) => selected?.id === noteToDelete.id ? notes.find((note) => note.id !== noteToDelete.id) || null : selected ); setNoteToDelete(null); toast.success('Note deleted'); } catch (error) { console.error('Failed to delete note:', error); toast.error('Unable to delete note'); } finally { setDeleting(false); } } async function openBacklink(link: Backlink) { const existing = notes.find((note) => note.id === link.id); if (existing) return setSelectedNote(existing); try { const response = await fetch(`/api/notes/${link.id}`); if (!response.ok) throw new Error('Unable to load linked note.'); const note = await response.json() as Note; setNotes((current) => [note, ...current]); setSelectedNote(note); } catch (error) { console.error('Failed to fetch linked note:', error); toast.error('Unable to open linked note'); } } if (loading) { return

Loading notes...

; } if (error) { return

{error}

; } return (

Notes

Connect ideas to the work they shape.

{/* Notes list */}
{notes.length === 0 ? (

No notes yet

) : (
{notes.map((note) => ( ))}
)}
{/* Note editor */} {selectedNote ? (
{ const title = e.target.value; setSelectedNote({ ...selectedNote, title, }); scheduleSave(selectedNote.id, { title }); }} className="min-w-0 flex-1 text-xl font-semibold outline-none" placeholder="Note title" /> {saveStatus} !open && setNoteToDelete(null)}> Delete {noteToDelete?.title}?This permanently deletes this note. Cancel{deleting ? 'Deleting...' : 'Delete note'}
Loading editor...
} > { setSelectedNote({ ...selectedNote, content }); scheduleSave(selectedNote.id, { content }); }} />
) : (

Select a note or create a new one

)} {/* Backlinks and graph */}
{backlinksLoading ?

Loading backlinks...

: backlinksError ?

{backlinksError}

: backlinks.length === 0 ? (

No backlinks

) : (
{backlinks.map((link) => ( ))}
)}
Loading graph...
} > ); }