'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: () => (
),
}
);
// Lazy load react-force-graph-2d (~120KB + three.js)
const NoteGraph = dynamic(
() => import('@/components/notes/note-graph').then((m) => m.NoteGraph),
{
ssr: false,
loading: () => (
),
}
);
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 ;
}
return (
Notes
Connect ideas to the work they shape.
{/* Notes list */}
{notes.length === 0 ? (
No notes yet
) : (
{notes.map((note) => (
))}
)}
{/* Note editor */}
{selectedNote ? (
}
>
{ setSelectedNote({ ...selectedNote, content }); scheduleSave(selectedNote.id, { content }); }}
/>
) : (
Select a note or create a new one
)}
{/* Backlinks and graph */}
Backlinks
Graph
{backlinksLoading ? Loading backlinks...
: backlinksError ? {backlinksError}
: backlinks.length === 0 ? (
No backlinks
) : (
{backlinks.map((link) => (
))}
)}
Loading graph...
}
>
);
}