'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: () => (
),
}
);
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 ;
}
return (
Notes
Connect ideas to the work they shape.
{domains.length > 1 && (
)}
{
createNoteWithContent(content);
}} />
{/* Notes list */}
{filteredNotes.length === 0 ? (
No notes found
) : (
{filteredNotes.map((note) => (
))}
)}
{/* Note editor */}
{selectedNote ? (
}
>
{
setSelectedNote({ ...selectedNote, content });
scheduleSave(selectedNote.id, { content });
}}
/>
) : (
Select a note or create a new one
)}
{/* Backlinks and outgoing links panel */}
Backlinks ({backlinks.length})
Links
{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)}
))}
)}
)}
);
}