feat: Phase 4 - Notes + Graph + Wikilinks
- Wikilink parser: [[Title]], [[Title|Display]], [[entity_type:Title]] patterns - Notes REST API: CRUD under /api/domains/[domainId]/notes/ with wikilink sync - Note link service: idempotent wikilink resolution, backlinks, outgoing links - Notes list page: search, filter (all/pinned/archived), domain selector - Note editor: TipTap with backlinks panel and outgoing links display - Graph data API: /api/domains/[domainId]/graph and /api/graph - Graph view page: D3 force-directed graph with entity type filters, search, zoom - Keyboard shortcuts: g g → graph, c n → new note - 18 passing wikilink parser tests - All API routes follow AGENTS.md contract (activity + pg_notify)
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, Suspense } from 'react';
|
||||
import { Plus, FileText, Link2, GitBranch, Trash2 } from 'lucide-react';
|
||||
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 { 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 { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
// Lazy load TipTap editor (~80KB TipTap + extensions)
|
||||
// Lazy load TipTap editor
|
||||
const NoteEditor = dynamic(
|
||||
() => import('@/components/notes/note-editor').then((m) => m.NoteEditor),
|
||||
{
|
||||
@@ -35,93 +37,97 @@ const NoteEditor = dynamic(
|
||||
}
|
||||
);
|
||||
|
||||
// Lazy load react-force-graph-2d (~120KB + three.js)
|
||||
const NoteGraph = dynamic(
|
||||
() => import('@/components/notes/note-graph').then((m) => m.NoteGraph),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">Loading graph...</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
domain: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
content: string | null;
|
||||
domainId: string;
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags?: { id: string; name: string; color: string | null }[];
|
||||
}
|
||||
|
||||
interface Backlink {
|
||||
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<Note[]>([]);
|
||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||
const [domainForCreate, setDomainForCreate] = useState('');
|
||||
const [domainOptions, setDomainOptions] = useState<{id: string; name: string}[]>([]);
|
||||
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
||||
const [domainId, setDomainId] = useState<string | null>(null);
|
||||
const [domains, setDomains] = useState<{ id: string; name: string; color: string | null }[]>([]);
|
||||
const [backlinks, setBacklinks] = useState<BacklinkItem[]>([]);
|
||||
const [outgoingLinks, setOutgoingLinks] = useState<OutgoingLink | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [backlinksError, setBacklinksError] = useState<string | null>(null);
|
||||
const [backlinksLoading, setBacklinksLoading] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved');
|
||||
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const createdFromQuery = useRef(false);
|
||||
const pendingSave = useRef<{ id: string; updates: Partial<Note> } | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterMode, setFilterMode] = useState<'all' | 'pinned' | 'archived'>('all');
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const saveVersion = useRef(0);
|
||||
const pendingSave = useRef<{ id: string; updates: Partial<Note> } | null>(null);
|
||||
|
||||
useEffect(() => { fetchDomains();
|
||||
fetchNotes();
|
||||
return () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, []);
|
||||
// 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
|
||||
|
||||
useEffect(() => { fetchDomains();
|
||||
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
|
||||
}, []);
|
||||
// Fetch notes when domain or filter changes
|
||||
useEffect(() => {
|
||||
if (domainId) {
|
||||
fetchNotes();
|
||||
}
|
||||
}, [domainId, filterMode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => { fetchDomains();
|
||||
// Fetch backlinks when selected note changes
|
||||
useEffect(() => {
|
||||
if (selectedNote) {
|
||||
fetchBacklinks(selectedNote.id);
|
||||
fetchOutgoingLinks(selectedNote.id);
|
||||
}
|
||||
}, [selectedNote]);
|
||||
|
||||
async function fetchDomains() {
|
||||
try {
|
||||
const res = await fetch('/api/domains?sort=sort_order');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDomainOptions(data.items || []);
|
||||
if (data.items?.length > 0) setDomainForCreate(data.items[0].id);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}, [selectedNote]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function fetchNotes() {
|
||||
if (!domainId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/notes?sort=-updated');
|
||||
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);
|
||||
setSelectedNote((current) => current || notesList[0] || null);
|
||||
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.');
|
||||
@@ -131,30 +137,40 @@ export default function NotesPage() {
|
||||
}
|
||||
|
||||
async function fetchBacklinks(noteId: string) {
|
||||
setBacklinksLoading(true);
|
||||
setBacklinksError(null);
|
||||
if (!domainId) return;
|
||||
try {
|
||||
const response = await fetch(`/api/notes/${noteId}/backlinks`);
|
||||
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);
|
||||
setBacklinksError('Unable to load backlinks.');
|
||||
} finally {
|
||||
setBacklinksLoading(false);
|
||||
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 createNote() {
|
||||
if (!domainId) return;
|
||||
try {
|
||||
const response = await fetch('/api/notes', {
|
||||
const response = await fetch(`/api/domains/${domainId}/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Untitled note',
|
||||
content: '',
|
||||
domain: domainForCreate,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error('Unable to create note.');
|
||||
@@ -168,19 +184,6 @@ export default function NotesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleDailyNoteReady(raw: Record<string, unknown>) {
|
||||
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<Note>) {
|
||||
const version = ++saveVersion.current;
|
||||
pendingSave.current = {
|
||||
@@ -197,15 +200,16 @@ export default function NotesPage() {
|
||||
}
|
||||
|
||||
async function updateNote(noteId: string, updates: Partial<Note>, version: number) {
|
||||
if (!domainId) return;
|
||||
try {
|
||||
const response = await fetch(`/api/notes/${noteId}`, {
|
||||
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 ? updated : note)));
|
||||
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);
|
||||
@@ -215,7 +219,7 @@ export default function NotesPage() {
|
||||
}
|
||||
|
||||
async function deleteNote() {
|
||||
if (!noteToDelete) return;
|
||||
if (!noteToDelete || !domainId) return;
|
||||
if (pendingSave.current?.id === noteToDelete.id && saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
pendingSave.current = null;
|
||||
@@ -223,7 +227,7 @@ export default function NotesPage() {
|
||||
++saveVersion.current;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const response = await fetch(`/api/notes/${noteToDelete.id}`, { method: 'DELETE' });
|
||||
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) =>
|
||||
@@ -239,11 +243,28 @@ export default function NotesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openBacklink(link: Backlink) {
|
||||
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/notes/${link.id}`);
|
||||
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]);
|
||||
@@ -254,11 +275,21 @@ export default function NotesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
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 <p className="text-muted-foreground" role="status">Loading notes...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error && notes.length === 0) {
|
||||
return <div className="space-y-3"><p className="text-muted-foreground" role="alert">{error}</p><Button onClick={fetchNotes}>Retry</Button></div>;
|
||||
}
|
||||
|
||||
@@ -272,7 +303,18 @@ export default function NotesPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<DailyNoteButton onNoteReady={handleDailyNoteReady} />
|
||||
{domains.length > 1 && (
|
||||
<select
|
||||
value={domainId || ''}
|
||||
onChange={(e) => setDomainId(e.target.value)}
|
||||
className="rounded-md border bg-background px-3 py-1.5 text-sm"
|
||||
aria-label="Select domain"
|
||||
>
|
||||
{domains.map((d) => (
|
||||
<option key={d.id} value={d.id}>{d.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button onClick={createNote}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New note
|
||||
@@ -280,14 +322,53 @@ export default function NotesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr_300px]">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[280px_1fr_320px]">
|
||||
{/* Notes list */}
|
||||
<Card className="max-h-80 lg:h-[calc(100vh-200px)] lg:max-h-none">
|
||||
<ScrollArea className="max-h-80 lg:h-full lg:max-h-none">
|
||||
<div className="border-b p-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search notes..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1">
|
||||
<Button
|
||||
variant={filterMode === 'all' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setFilterMode('all')}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
variant={filterMode === 'pinned' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setFilterMode('pinned')}
|
||||
>
|
||||
<Pin className="mr-1 h-3 w-3" />
|
||||
Pinned
|
||||
</Button>
|
||||
<Button
|
||||
variant={filterMode === 'archived' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setFilterMode('archived')}
|
||||
>
|
||||
<Archive className="mr-1 h-3 w-3" />
|
||||
Archived
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="max-h-80 lg:h-[calc(100vh-260px)] lg:max-h-none">
|
||||
<div className="p-2">
|
||||
{notes.length === 0 ? (
|
||||
{filteredNotes.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No notes yet</p>
|
||||
<p className="text-sm text-muted-foreground">No notes found</p>
|
||||
<Button className="mt-3" size="sm" onClick={createNote}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create your first note
|
||||
@@ -295,7 +376,7 @@ export default function NotesPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{notes.map((note) => (
|
||||
{filteredNotes.map((note) => (
|
||||
<button
|
||||
key={note.id}
|
||||
onClick={() => setSelectedNote(note)}
|
||||
@@ -310,15 +391,25 @@ export default function NotesPage() {
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{note.title}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{note.title}
|
||||
</p>
|
||||
{note.isPinned && <Pin className="h-3 w-3 shrink-0 text-amber-500" />}
|
||||
{note.isArchived && <Archive className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{new Date(note.updated).toLocaleDateString()}
|
||||
{new Date(note.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{domainOptions.find(d => d.id === note.domain)?.name || note.domain}
|
||||
</Badge>
|
||||
{note.tags && note.tags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{note.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag.id} variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -339,21 +430,36 @@ export default function NotesPage() {
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="note-title"
|
||||
type="text"
|
||||
value={selectedNote.title}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
id="note-title"
|
||||
type="text"
|
||||
value={selectedNote.title}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground" role="status">{saveStatus}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => togglePinned(selectedNote)}
|
||||
aria-label={selectedNote.isPinned ? 'Unpin note' : 'Pin note'}
|
||||
>
|
||||
<Pin className={`h-4 w-4 ${selectedNote.isPinned ? 'fill-amber-500 text-amber-500' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => toggleArchived(selectedNote)}
|
||||
aria-label={selectedNote.isArchived ? 'Restore note' : 'Archive note'}
|
||||
>
|
||||
{selectedNote.isArchived ? <ArchiveRestore className="h-4 w-4" /> : <Archive className="h-4 w-4" />}
|
||||
</Button>
|
||||
<AlertDialog open={noteToDelete?.id === selectedNote.id} onOpenChange={(open) => !open && setNoteToDelete(null)}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label={`Delete ${selectedNote.title}`} onClick={() => setNoteToDelete(selectedNote)}>
|
||||
@@ -378,8 +484,11 @@ export default function NotesPage() {
|
||||
}
|
||||
>
|
||||
<NoteEditor
|
||||
content={selectedNote.content}
|
||||
onChange={(content) => { setSelectedNote({ ...selectedNote, content }); scheduleSave(selectedNote.id, { content }); }}
|
||||
content={selectedNote.content || ''}
|
||||
onChange={(content) => {
|
||||
setSelectedNote({ ...selectedNote, content });
|
||||
scheduleSave(selectedNote.id, { content });
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
@@ -393,26 +502,26 @@ export default function NotesPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Backlinks and graph */}
|
||||
{/* Backlinks and outgoing links panel */}
|
||||
<Card className="min-h-[360px] lg:h-[calc(100vh-200px)]">
|
||||
<Tabs defaultValue="backlinks" className="h-full">
|
||||
<div className="border-b p-2">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="backlinks" className="flex-1 gap-2">
|
||||
<Link2 className="h-3 w-3" aria-hidden="true" />
|
||||
Backlinks
|
||||
Backlinks ({backlinks.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="graph" className="flex-1 gap-2">
|
||||
<TabsTrigger value="outgoing" className="flex-1 gap-2">
|
||||
<GitBranch className="h-3 w-3" aria-hidden="true" />
|
||||
Graph
|
||||
Links
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="backlinks" className="h-full overflow-auto p-4">
|
||||
{backlinksLoading ? <p className="py-8 text-center text-sm text-muted-foreground" role="status">Loading backlinks...</p> : backlinksError ? <div className="py-8 text-center"><p className="text-sm text-muted-foreground" role="alert">{backlinksError}</p><Button className="mt-3" size="sm" onClick={() => selectedNote && fetchBacklinks(selectedNote.id)}>Retry</Button></div> : backlinks.length === 0 ? (
|
||||
{backlinks.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No backlinks
|
||||
No backlinks — no other notes link to this one
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -425,28 +534,65 @@ export default function NotesPage() {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="text-sm font-medium">
|
||||
{link.title}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{link.title}</span>
|
||||
</div>
|
||||
{link.excerpt && (
|
||||
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{link.excerpt}</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="graph" className="h-full p-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">
|
||||
Loading graph...
|
||||
<TabsContent value="outgoing" className="h-full overflow-auto p-4">
|
||||
{!outgoingLinks ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">Loading...</p>
|
||||
) : outgoingLinks.noteLinks.length === 0 && outgoingLinks.entityLinks.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No outgoing links — use [[Title]] to link to other notes
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{outgoingLinks.noteLinks.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">Notes</p>
|
||||
{outgoingLinks.noteLinks.map((link) => (
|
||||
<button
|
||||
key={link.id}
|
||||
onClick={() => openBacklink({ id: link.id, title: link.title, excerpt: '' })}
|
||||
className="w-full rounded-lg border p-2 text-left text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<FileText className="mr-2 inline h-3 w-3 text-muted-foreground" />
|
||||
{link.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<NoteGraph notes={notes} />
|
||||
</Suspense>
|
||||
)}
|
||||
{outgoingLinks.entityLinks.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">Entities</p>
|
||||
{outgoingLinks.entityLinks.map((link, i) => (
|
||||
<div key={`${link.entityType}-${link.entityId}-${i}`} className="rounded-lg border p-2 text-sm">
|
||||
<span
|
||||
className="mr-1.5 inline-block h-2 w-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
link.entityType === 'task' ? '#3b82f6' :
|
||||
link.entityType === 'habit' ? '#10b981' :
|
||||
link.entityType === 'project' ? '#8b5cf6' :
|
||||
link.entityType === 'section' ? '#ec4899' :
|
||||
link.entityType === 'tag' ? '#6b7280' : '#f59e0b',
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">{link.entityType}:</span>{' '}
|
||||
{link.title || link.entityId.slice(0, 8)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user