import { useState, useRef, useEffect, memo, useCallback } from "react"; import { createRoute } from "@tanstack/react-router"; import { Route as appRoute } from "../_app"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { api, useApiQuery } from "@/lib/api"; import { useApiDomain } from "@/lib/stores/use-active-domain-store"; import { useRealtime } from "@/hooks/use-realtime"; import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog"; import { Plus, Trash2, Search, Pin, FileText, Link as LinkIcon, History } from "lucide-react"; import { NoteEditor } from "@/components/entities/note-editor"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; import { showUndoToast } from "@/lib/undo/use-undo-toast"; import type { Note, PaginatedResponse } from "@/lib/types"; import { format, parseISO } from "date-fns"; // Completely uncontrolled title input - uses ref to avoid any re-render const NoteTitleInput = memo(function NoteTitleInput({ noteId, initialTitle }: { noteId: string; initialTitle: string }) { const inputRef = useRef(null); const queryClient = useQueryClient(); useEffect(() => { if (inputRef.current) { inputRef.current.value = initialTitle; } }, [initialTitle, noteId]); const handleBlur = () => { const newTitle = inputRef.current?.value || ""; if (newTitle !== initialTitle) { api.patch("/notes/" + noteId, { title: newTitle }).then(() => { queryClient.invalidateQueries({ queryKey: ["notes"] }); }); } }; return ( e.stopPropagation()} onBlur={handleBlur} autoFocus className="flex h-9 w-full rounded-md border-0 bg-transparent px-0 py-1 text-lg font-semibold shadow-none transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed disabled:opacity-50" /> ); }); // Count words in HTML content (rough estimate) function countWords(html: string | null): number { if (!html) return 0; return html.replace(/<[^>]+>/g, "").trim().split(/\s+/).filter(Boolean).length; } // Memoized right pane - only re-renders when note changes, not on parent re-renders const NoteEditorPane = memo(function NoteEditorPane({ note, onDelete, onOpenNote }: { note: Note; onDelete: (id: string) => void; onOpenNote: (note: Note) => void }) { const [showBacklinks, setShowBacklinks] = useState(false); const [showVersions, setShowVersions] = useState(false); const [versions, setVersions] = useState<{ id: string; createdAt: string; action?: string; changes?: Record | null }[]>([]); const queryClient = useQueryClient(); const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: string; data: any }) => api.patch("/notes/" + id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); }, }); const handleSave = useCallback((html: string) => { updateMutation.mutate({ id: note.id, data: { content: html } }); }, [note.id, updateMutation]); return ( <>
Delete Note Are you sure you want to delete "{note.title}"? Cancel onDelete(note.id)} className="bg-destructive text-destructive-foreground">Delete
{/* Metadata strip */}
{format(parseISO(note.updatedAt), "yyyy-MM-dd HH:mm")} · {countWords(note.content)} words {note.isPinned && ( <> · pinned )} {note.tags && note.tags.length > 0 && ( <> · {note.tags.length} tag{note.tags.length !== 1 ? 's' : ''} )} {note.backlinks && note.backlinks.length > 0 && ( <> · {note.backlinks.length} backlink{note.backlinks.length !== 1 ? 's' : ''} )}
{/* Backlinks section */} {showBacklinks && note.backlinks && note.backlinks.length > 0 && (

Linked from

{note.backlinks.map((bl) => (
{ const linked: Note = { ...note, id: bl.id, title: bl.title }; onOpenNote(linked); }} > {bl.title}
))}
)} {/* Version history */} {showVersions && (

Version History

{versions.length === 0 ? (

No versions yet.

) : (
{versions.map((v) => (
{format(parseISO(v.createdAt), "yyyy-MM-dd HH:mm")} {v.action && {v.action}}
))}
)}
)} ); }); function NotesPage() { const queryClient = useQueryClient(); const [search, setSearch] = useState(""); const [selectedNoteId, setSelectedNoteId] = useState(null); const selectedNoteRef = useRef(null); useRealtime({ enabled: true }); useOpenCreateDialog("note", () => createMutation.mutate()); const activeDomainId = useApiDomain(); const notesQueryUrl = () => "/notes?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + (search ? "&search=" + encodeURIComponent(search) : ""); const { data: notesData, isLoading } = useApiQuery>( ["notes", activeDomainId, search], notesQueryUrl() ); const notes = notesData?.items || []; const hasMoreNotes = notes.length < (notesData?.totalItems || 0); const [loadingMoreNotes, setLoadingMoreNotes] = useState(false); const loadMoreNotes = async () => { if (!hasMoreNotes || loadingMoreNotes) return; setLoadingMoreNotes(true); try { const next = await api.get>(notesQueryUrl() + "&offset=" + notes.length); queryClient.setQueryData>(["notes", activeDomainId, search], (old) => { if (!old) return old; const seen = new Set(old.items.map((n) => n.id)); return { ...old, items: [...old.items, ...next.items.filter((n) => !seen.has(n.id))] }; }); } finally { setLoadingMoreNotes(false); } }; const createMutation = useMutation({ mutationFn: () => api.post("/notes", { title: "Untitled", content: "", ...(activeDomainId ? { domain: activeDomainId } : {}) }), onSuccess: (note) => { queryClient.invalidateQueries({ queryKey: ["notes"] }); selectedNoteRef.current = note; setSelectedNoteId(note.id); }, }); const deleteMutation = useMutation({ mutationFn: (id: string) => api.delete("/notes/" + id), onSuccess: (_, deletedId) => { const note = notes.find((n) => n.id === deletedId) || selectedNoteRef.current; if (note) { showUndoToast( "note", deletedId, { title: note.title, content: note.content, domain: note.domainId || undefined }, queryClient, ["notes"], ); } queryClient.invalidateQueries({ queryKey: ["notes"] }); selectedNoteRef.current = null; setSelectedNoteId(null); }, }); const selectNote = async (note: Note) => { try { const detail = await api.get("/notes/" + note.id); selectedNoteRef.current = detail; } catch { selectedNoteRef.current = note; } setSelectedNoteId(note.id); }; const selectedNote = selectedNoteRef.current; const handleDeleteNote = useCallback((id: string) => { deleteMutation.mutate(id); }, [deleteMutation]); return (
{/* Left pane - note list */}
setSearch(e.target.value)} className="pl-8 h-8 text-sm" aria-label="Search notes" />
{isLoading ? (
Loading...
) : notes.length === 0 ? (

No notes yet

) : (
{notes.map((note) => ( ))}
)} {hasMoreNotes && (
)}
{/* Right pane - editor (memoized, won't re-render on parent state changes) */}
{selectedNote ? ( ) : (

Select a note or create a new one

)}
); } export const Route = createRoute({ getParentRoute: () => appRoute, path: "/notes", component: NotesPage, });