'use client'; import { useState, useEffect, useRef, useCallback, Suspense } from 'react'; import { Plus, FileText, Link2, GitBranch, Trash2, Pin, Archive, Search, PinOff, ArchiveRestore } from 'lucide-react'; import dynamic from 'next/dynamic'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { NoteTemplates } from '@/components/notes/note-templates'; import { Card } from '@/components/ui/card'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; // Lazy load TipTap editor const NoteEditor = dynamic( () => import('@/components/notes/note-editor').then((m) => m.NoteEditor), { ssr: false, loading: () => (
Loading editor...
), } ); interface Note { id: string; title: string; content: string | null; domainId: string; isPinned: boolean; isArchived: boolean; createdAt: string; updatedAt: string; tags?: { id: string; name: string; color: string | null }[]; } interface BacklinkItem { id: string; title: string; excerpt: string; } interface OutgoingLink { noteLinks: { id: string; title: string }[]; entityLinks: { entityType: string; entityId: string; title: string | null }[]; } export default function NotesPage() { const [notes, setNotes] = useState([]); const [selectedNote, setSelectedNote] = useState(null); const [domainId, setDomainId] = useState(null); const [domains, setDomains] = useState<{ id: string; name: string; color: string | null }[]>([]); const [backlinks, setBacklinks] = useState([]); const [outgoingLinks, setOutgoingLinks] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved'); const [noteToDelete, setNoteToDelete] = useState(null); const [deleting, setDeleting] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [filterMode, setFilterMode] = useState<'all' | 'pinned' | 'archived'>('all'); const saveTimer = useRef | null>(null); const saveVersion = useRef(0); const pendingSave = useRef<{ id: string; updates: Partial } | null>(null); // Fetch domains on mount useEffect(() => { fetch('/api/domains?sort=sort_order') .then((res) => res.json()) .then((data) => { const items = data.items || []; setDomains(items); if (items.length > 0 && !domainId) { setDomainId(items[0].id); } }) .catch(() => {}); }, []); // eslint-disable-line react-hooks/exhaustive-deps // Fetch notes when domain or filter changes useEffect(() => { if (domainId) { fetchNotes(); } }, [domainId, filterMode]); // eslint-disable-line react-hooks/exhaustive-deps // Fetch backlinks when selected note changes useEffect(() => { if (selectedNote) { fetchBacklinks(selectedNote.id); fetchOutgoingLinks(selectedNote.id); } }, [selectedNote]); // eslint-disable-line react-hooks/exhaustive-deps async function fetchNotes() { if (!domainId) return; setLoading(true); setError(null); try { const params = new URLSearchParams(); if (filterMode === 'pinned') params.set('pinned', 'true'); else if (filterMode === 'archived') params.set('archived', 'true'); else params.set('archived', 'false'); if (searchQuery) params.set('search', searchQuery); params.set('sort', 'updated_at'); params.set('order', 'desc'); const response = await fetch(`/api/domains/${domainId}/notes?${params}`); if (!response.ok) throw new Error('Unable to load notes.'); const data = await response.json(); const notesList = data.items || []; setNotes(notesList); if (!selectedNote && notesList.length > 0) { setSelectedNote(notesList[0]); } } 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) { if (!domainId) return; try { const response = await fetch(`/api/domains/${domainId}/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); setBacklinks([]); } } async function fetchOutgoingLinks(noteId: string) { if (!domainId) return; try { const response = await fetch(`/api/domains/${domainId}/notes/${noteId}`); if (!response.ok) throw new Error('Unable to load note.'); const data = await response.json(); setOutgoingLinks(data.outgoingLinks || null); } catch (error) { console.error('Failed to fetch outgoing links:', error); setOutgoingLinks(null); } } async function createNoteWithContent(content: string) { if (!domainId) return; try { const response = await fetch("/api/domains/" + domainId + "/notes", { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Untitled note', content, }), }); 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 from template'); } catch (error) { console.error('Failed to create note:', error); toast.error('Unable to create note'); } } async function createNote() { if (!domainId) return; try { const response = await fetch(`/api/domains/${domainId}/notes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Untitled note', content: '', }), }); 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 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) { if (!domainId) return; try { const response = await fetch(`/api/domains/${domainId}/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 ? { ...note, ...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 || !domainId) return; if (pendingSave.current?.id === noteToDelete.id && saveTimer.current) { clearTimeout(saveTimer.current); pendingSave.current = null; } ++saveVersion.current; setDeleting(true); const deletedNote = { ...noteToDelete }; try { const response = await fetch(`/api/domains/${domainId}/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', { action: { label: 'Undo', onClick: async () => { try { const res = await fetch(`/api/domains/${domainId}/notes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: deletedNote.title, content: deletedNote.content, }), }); if (!res.ok) throw new Error(); const restored = await res.json(); setNotes((current) => [restored, ...current]); setSelectedNote(restored); toast.success('Note restored'); } catch { toast.error('Unable to restore note'); } }, }, }); } catch (error) { console.error('Failed to delete note:', error); toast.error('Unable to delete note'); } finally { setDeleting(false); } } async function togglePinned(note: Note) { const newPinned = !note.isPinned; setSelectedNote((prev) => prev?.id === note.id ? { ...prev, isPinned: newPinned } : prev); setNotes((current) => current.map((n) => n.id === note.id ? { ...n, isPinned: newPinned } : n)); await updateNote(note.id, { isPinned: newPinned }, ++saveVersion.current); toast.success(newPinned ? 'Note pinned' : 'Note unpinned'); } async function toggleArchived(note: Note) { const newArchived = !note.isArchived; setSelectedNote((prev) => prev?.id === note.id ? { ...prev, isArchived: newArchived } : prev); setNotes((current) => current.map((n) => n.id === note.id ? { ...n, isArchived: newArchived } : n)); await updateNote(note.id, { isArchived: newArchived }, ++saveVersion.current); toast.success(newArchived ? 'Note archived' : 'Note restored'); } async function openBacklink(link: BacklinkItem) { const existing = notes.find((note) => note.id === link.id); if (existing) return setSelectedNote(existing); if (!domainId) return; try { const response = await fetch(`/api/domains/${domainId}/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'); } } const handleRefresh = useCallback(() => { fetchNotes(); }, [domainId, filterMode, searchQuery]); // eslint-disable-line react-hooks/exhaustive-deps // Filter notes locally for search const filteredNotes = notes.filter((note) => { if (searchQuery && !note.title.toLowerCase().includes(searchQuery.toLowerCase())) return false; return true; }); if (loading && notes.length === 0) { return

Loading notes...

; } if (error && notes.length === 0) { return

{error}

; } return (

Notes

Connect ideas to the work they shape.

{domains.length > 1 && ( )} { createNoteWithContent(content); }} />
{/* Notes list */}
setSearchQuery(e.target.value)} className="pl-8" />
{filteredNotes.length === 0 ? (

No notes found

) : (
{filteredNotes.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 outgoing links panel */}
{backlinks.length === 0 ? (

No backlinks — no other notes link to this one

) : (
{backlinks.map((link) => ( ))}
)}
{!outgoingLinks ? (

Loading...

) : outgoingLinks.noteLinks.length === 0 && outgoingLinks.entityLinks.length === 0 ? (

No outgoing links — use [[Title]] to link to other notes

) : (
{outgoingLinks.noteLinks.length > 0 && (

Notes

{outgoingLinks.noteLinks.map((link) => ( ))}
)} {outgoingLinks.entityLinks.length > 0 && (

Entities

{outgoingLinks.entityLinks.map((link, i) => (
{link.entityType}:{' '} {link.title || link.entityId.slice(0, 8)}
))}
)}
)}
); }