Files
ProjectE/apps/web-legacy/app/(dashboard)/notes/page.tsx
T
Hermes fca56ab77e T1/Phase 1: scaffold Vite SPA + Hono API + Bun worker
- apps/web: Vite + React 19 + TanStack Router/Query + shadcn/ui
   - apps/api: Hono + Bun on :3001 with /api/health, /api/auth/*, /mcp stubs
   - apps/worker: Bun worker stub, DB connection, graceful SIGTERM
   - apps/web-legacy/: old Next.js code moved aside (preserved for T2-T8 reference)
   - Dockerfiles: api (Bun), worker (Bun), spa (multi-stage Caddy)
   - Caddyfile: serves dist + reverse-proxies /api/* + /mcp to api
   - docker-compose.yml: 4-service target (api, spa, db, worker)
   - packages/db/src/client.ts: shared Drizzle client for api + worker
   - db/client.ts: root-level alias for convenience

   Parent: t_e1cbd87d -> t_24c9c3fd (T0)
2026-08-01 01:15:31 +00:00

653 lines
26 KiB
TypeScript

'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: () => (
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">Loading editor...</div>
</div>
),
}
);
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<Note[]>([]);
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
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 [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved');
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const [deleting, setDeleting] = useState(false);
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);
// 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<Note>) {
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<Note>, 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 <p className="text-muted-foreground" role="status">Loading notes...</p>;
}
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>;
}
return (
<div>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold">Notes</h1>
<p className="mt-1 text-muted-foreground">
Connect ideas to the work they shape.
</p>
</div>
<div className="flex items-center gap-3">
{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>
)}
<NoteTemplates onCreateFromTemplate={(content) => {
createNoteWithContent(content);
}} />
<Button onClick={createNote}>
<Plus className="mr-2 h-4 w-4" />
New note
</Button>
</div>
</div>
<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">
<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">
{filteredNotes.length === 0 ? (
<div className="py-8 text-center">
<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
</Button>
</div>
) : (
<div className="space-y-1">
{filteredNotes.map((note) => (
<button
key={note.id}
onClick={() => setSelectedNote(note)}
aria-label={`Open note: ${note.title}`}
aria-current={selectedNote?.id === note.id ? 'true' : undefined}
className={`w-full rounded-lg p-3 text-left transition-colors ${
selectedNote?.id === note.id
? 'bg-accent'
: 'hover:bg-accent/50'
}`}
>
<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">
<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.updatedAt).toLocaleDateString()}
</p>
{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>
))}
</div>
)}
</div>
</ScrollArea>
</Card>
{/* Note editor */}
<Card className="min-h-[420px] lg:h-[calc(100vh-200px)]">
{selectedNote ? (
<div className="flex h-full flex-col">
<div className="border-b p-4">
<label htmlFor="note-title" className="sr-only">
Note title
</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"
/>
<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)}>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader><AlertDialogTitle>Delete {noteToDelete?.title}?</AlertDialogTitle><AlertDialogDescription>This permanently deletes this note.</AlertDialogDescription></AlertDialogHeader>
<AlertDialogFooter><AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel><AlertDialogAction onClick={deleteNote} disabled={deleting}>{deleting ? 'Deleting...' : 'Delete note'}</AlertDialogAction></AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">
Loading editor...
</div>
</div>
}
>
<NoteEditor
content={selectedNote.content || ''}
onChange={(content) => {
setSelectedNote({ ...selectedNote, content });
scheduleSave(selectedNote.id, { content });
}}
/>
</Suspense>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">
Select a note or create a new one
</p>
</div>
)}
</Card>
{/* 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.length})
</TabsTrigger>
<TabsTrigger value="outgoing" className="flex-1 gap-2">
<GitBranch className="h-3 w-3" aria-hidden="true" />
Links
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="backlinks" className="h-full overflow-auto p-4">
{backlinks.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No backlinks no other notes link to this one
</p>
) : (
<div className="space-y-2">
{backlinks.map((link) => (
<button
key={link.id}
onClick={() => openBacklink(link)}
aria-label={`Open linked note: ${link.title}`}
className="w-full rounded-lg border p-3 text-left transition-colors hover:bg-accent"
>
<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>
</div>
{link.excerpt && (
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{link.excerpt}</p>
)}
</button>
))}
</div>
)}
</TabsContent>
<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>
)}
{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>
</div>
</div>
);
}