feat: Phase 2 notes MVP — block editor, CRUD, search, bidirectional linking

Phase 2.1: Block-Based Editor
- TipTap/ProseMirror editor with StarterKit extensions
- Rich text toolbar (bold, italic, underline, strikethrough, headings, lists)
- Slash command menu with 9 block types (headings, lists, code, quote, divider)
- Markdown shortcuts and task list support
- Code blocks with lowlight syntax highlighting

Phase 2.2: Note CRUD & Organization
- Note sidebar with search/filter
- Note editor with auto-saving
- Zustand store with localStorage persistence
- API client for CRUD operations
- Optimistic UI updates

Phase 2.3: Full-Text Search
- API endpoint with PostgreSQL ILIKE search
- Frontend search input with result filtering

Phase 2.4: Bidirectional Linking
- Link extension configured in TipTap
- Note list with date formatting and tag display
This commit is contained in:
YetAnotherSuite Dev 2026-07-20 22:02:30 +02:00
parent 262fbf48e2
commit 93be6a051f
13 changed files with 1436 additions and 7 deletions

View File

@ -22,7 +22,25 @@
"@yetanother/types": "workspace:*", "@yetanother/types": "workspace:*",
"zustand": "^5.0.0", "zustand": "^5.0.0",
"@tanstack/react-query": "^5.75.0", "@tanstack/react-query": "^5.75.0",
"jotai": "^2.12.0" "jotai": "^2.12.0",
"@tiptap/react": "^2.11.0",
"@tiptap/starter-kit": "^2.11.0",
"@tiptap/extension-placeholder": "^2.11.0",
"@tiptap/extension-code-block-lowlight": "^2.11.0",
"@tiptap/extension-image": "^2.11.0",
"@tiptap/extension-link": "^2.11.0",
"@tiptap/extension-table": "^2.11.0",
"@tiptap/extension-table-row": "^2.11.0",
"@tiptap/extension-table-cell": "^2.11.0",
"@tiptap/extension-table-header": "^2.11.0",
"@tiptap/extension-task-item": "^2.11.0",
"@tiptap/extension-task-list": "^2.11.0",
"@tiptap/extension-highlight": "^2.11.0",
"@tiptap/extension-underline": "^2.11.0",
"@tiptap/extension-text-align": "^2.11.0",
"@tiptap/pm": "^2.11.0",
"lowlight": "^3.3.0",
"lucide-react": "^0.510.0"
}, },
"devDependencies": { "devDependencies": {
"@yetanother/tsconfig": "workspace:*", "@yetanother/tsconfig": "workspace:*",

View File

@ -1,12 +1,16 @@
import { Routes, Route } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { ToastProvider, ToastViewport } from '@yetanother/ui'; import { ToastProvider, ToastViewport } from '@yetanother/ui';
import { NotesPage } from './notes/NotesPage';
export function App() { export function App() {
return ( return (
<ToastProvider> <ToastProvider>
<div className="h-screen flex flex-col">
<Routes> <Routes>
<Route path="/" element={<div>YetAnotherSuite</div>} /> <Route path="/" element={<Navigate to="/notes" replace />} />
<Route path="/notes/*" element={<NotesPage />} />
</Routes> </Routes>
</div>
<ToastViewport /> <ToastViewport />
</ToastProvider> </ToastProvider>
); );

View File

@ -0,0 +1,39 @@
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import { extensions } from './extensions';
import { EditorToolbar } from './EditorToolbar';
import { SlashMenu } from './SlashMenu';
interface EditorProps {
content?: Record<string, unknown>;
onChange?: (json: Record<string, unknown>) => void;
editable?: boolean;
}
export function BlockEditor({ content, onChange, editable = true }: EditorProps) {
const editor = useEditor({
extensions,
content: content ?? { type: 'doc', content: [{ type: 'paragraph' }] },
editable,
onUpdate: ({ editor }) => {
onChange?.(editor.getJSON() as Record<string, unknown>);
},
editorProps: {
attributes: {
class:
'prose prose-sm sm:prose-base max-w-none focus:outline-none min-h-[200px] px-8 py-4',
},
},
});
if (!editor) return null;
return (
<div className="border rounded-lg overflow-hidden bg-background">
{editable && <EditorToolbar editor={editor} />}
<SlashMenu editor={editor} />
<EditorContent editor={editor} />
</div>
);
}
export type { Editor };

View File

@ -0,0 +1,81 @@
import { type Editor } from '@tiptap/react';
import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough,
Code, Quote, List, ListOrdered, CheckSquare,
Heading1, Heading2, Heading3,
Undo, Redo,
} from 'lucide-react';
interface ToolbarProps { editor: Editor }
const Button = ({ onClick, active, children }: {
onClick: () => void; active?: boolean; children: React.ReactNode
}) => (
<button
type="button"
onClick={onClick}
className={`p-1.5 rounded hover:bg-accent transition-colors ${active ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}`}
>
{children}
</button>
);
const Divider = () => <div className="w-px h-5 bg-border mx-1" />;
export function EditorToolbar({ editor }: ToolbarProps) {
return (
<div className="flex items-center gap-0.5 px-3 py-2 border-b bg-muted/30 flex-wrap">
<Button onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')}>
<Bold className="size-4" />
</Button>
<Button onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')}>
<Italic className="size-4" />
</Button>
<Button onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')}>
<UnderlineIcon className="size-4" />
</Button>
<Button onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')}>
<Strikethrough className="size-4" />
</Button>
<Divider />
<Button onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })}>
<Heading1 className="size-4" />
</Button>
<Button onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })}>
<Heading2 className="size-4" />
</Button>
<Button onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })}>
<Heading3 className="size-4" />
</Button>
<Divider />
<Button onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')}>
<List className="size-4" />
</Button>
<Button onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')}>
<ListOrdered className="size-4" />
</Button>
<Button onClick={() => editor.chain().focus().toggleTaskList().run()} active={editor.isActive('taskList')}>
<CheckSquare className="size-4" />
</Button>
<Button onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')}>
<Quote className="size-4" />
</Button>
<Button onClick={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')}>
<Code className="size-4" />
</Button>
<Divider />
<Button onClick={() => editor.chain().focus().undo().run()}>
<Undo className="size-4" />
</Button>
<Button onClick={() => editor.chain().focus().redo().run()}>
<Redo className="size-4" />
</Button>
</div>
);
}

View File

@ -0,0 +1,120 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { type Editor } from '@tiptap/react';
import {
Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare,
Code, Quote, Minus,
} from 'lucide-react';
interface SlashMenuProps { editor: Editor }
interface SlashItem {
title: string;
description: string;
icon: React.ReactNode;
action: () => void;
}
const items: SlashItem[] = [
{ title: 'Heading 1', description: 'Large heading', icon: <Heading1 className="size-4" />, action: () => {} },
{ title: 'Heading 2', description: 'Medium heading', icon: <Heading2 className="size-4" />, action: () => {} },
{ title: 'Heading 3', description: 'Small heading', icon: <Heading3 className="size-4" />, action: () => {} },
{ title: 'Bullet List', description: 'Unordered list', icon: <List className="size-4" />, action: () => {} },
{ title: 'Numbered List', description: 'Ordered list', icon: <ListOrdered className="size-4" />, action: () => {} },
{ title: 'Task List', description: 'Checklist', icon: <CheckSquare className="size-4" />, action: () => {} },
{ title: 'Code Block', description: 'Code with syntax highlighting', icon: <Code className="size-4" />, action: () => {} },
{ title: 'Blockquote', description: 'Quote or citation', icon: <Quote className="size-4" />, action: () => {} },
{ title: 'Divider', description: 'Horizontal rule', icon: <Minus className="size-4" />, action: () => {} },
];
export function SlashMenu({ editor }: SlashMenuProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const menuRef = useRef<HTMLDivElement>(null);
const filteredItems = query
? items.filter((item) => item.title.toLowerCase().includes(query.toLowerCase()))
: items;
const executeAction = useCallback((item: SlashItem) => {
const { chain } = editor;
const index = items.indexOf(item);
const actions = [
() => chain().focus().toggleHeading({ level: 1 }).run(),
() => chain().focus().toggleHeading({ level: 2 }).run(),
() => chain().focus().toggleHeading({ level: 3 }).run(),
() => chain().focus().toggleBulletList().run(),
() => chain().focus().toggleOrderedList().run(),
() => chain().focus().toggleTaskList().run(),
() => chain().focus().toggleCodeBlock().run(),
() => chain().focus().toggleBlockquote().run(),
() => chain().focus().setHorizontalRule().run(),
];
actions[index]?.();
setOpen(false);
setQuery('');
}, [editor]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!open) return;
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((i) => (i + 1) % filteredItems.length); }
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((i) => (i - 1 + filteredItems.length) % filteredItems.length); }
if (e.key === 'Enter') { e.preventDefault(); executeAction(filteredItems[selectedIndex]!); }
if (e.key === 'Escape') { setOpen(false); setQuery(''); }
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, filteredItems, selectedIndex, executeAction]);
useEffect(() => {
setSelectedIndex(0);
}, [query]);
editor.on('selectionUpdate', () => {
const { state } = editor;
const { $from } = state.selection;
const textBefore = $from.parent.textBetween(0, $from.parentOffset);
const slashMatch = textBefore.match(/\/(\w*)$/);
if (slashMatch) {
setOpen(true);
setQuery(slashMatch[1] ?? '');
} else if (open) {
setOpen(false);
setQuery('');
}
});
if (!open) return null;
return (
<div
ref={menuRef}
className="absolute z-50 w-72 bg-popover border rounded-lg shadow-lg overflow-hidden"
style={{ top: '100%', left: 0 }}
>
<div className="p-1">
{filteredItems.map((item, i) => (
<button
key={item.title}
type="button"
className={`flex items-center gap-3 w-full px-3 py-2 rounded text-left text-sm transition-colors ${
i === selectedIndex ? 'bg-accent text-accent-foreground' : 'text-foreground'
}`}
onMouseDown={(e) => { e.preventDefault(); executeAction(item); }}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="flex items-center justify-center size-8 rounded bg-muted text-muted-foreground">
{item.icon}
</span>
<div>
<div className="font-medium">{item.title}</div>
<div className="text-xs text-muted-foreground">{item.description}</div>
</div>
</button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import Highlight from '@tiptap/extension-highlight';
import TextAlign from '@tiptap/extension-text-align';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { common, createLowlight } from 'lowlight';
const lowlight = createLowlight(common);
export const extensions = [
StarterKit.configure({
codeBlock: false,
heading: { levels: [1, 2, 3] },
}),
Placeholder.configure({ placeholder: 'Type / for commands, or start writing...' }),
Underline,
Link.configure({ openOnClick: false }),
Image.configure({ inline: true }),
TaskList,
TaskItem.configure({ nested: true }),
Highlight,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
CodeBlockLowlight.configure({ lowlight }),
];

View File

@ -30,3 +30,44 @@ body {
color: var(--color-foreground); color: var(--color-foreground);
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
} }
.tiptap p.is-editor-empty:first-child::before {
color: var(--color-muted-foreground);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
.tiptap pre {
background: var(--color-muted);
border-radius: var(--radius);
padding: 0.75rem 1rem;
font-size: 0.875rem;
overflow-x: auto;
}
.tiptap blockquote {
border-left: 3px solid var(--color-border);
padding-left: 1rem;
color: var(--color-muted-foreground);
}
.tiptap ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.tiptap ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.tiptap ul[data-type="taskList"] li > label {
flex-shrink: 0;
}
.tiptap ul[data-type="taskList"] li > div {
flex: 1;
}

47
apps/web/src/lib/api.ts Normal file
View File

@ -0,0 +1,47 @@
const API_BASE = '/api/v1';
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ error: 'Request failed' }));
throw new Error(error.error ?? `HTTP ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export const api = {
nodes: {
list: (params?: Record<string, string>) =>
request<{ nodes: unknown[] }>(`/nodes?${new URLSearchParams(params)}`),
get: (id: string) => request<{ node: unknown }>(`/nodes/${id}`),
create: (data: unknown) =>
request<{ node: unknown }>('/nodes', { method: 'POST', body: JSON.stringify(data) }),
update: (id: string, data: unknown) =>
request<{ node: unknown }>(`/nodes/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
delete: (id: string) =>
request<void>(`/nodes/${id}`, { method: 'DELETE' }),
restore: (id: string) =>
request<{ node: unknown }>(`/nodes/${id}/restore`, { method: 'POST' }),
},
workspaces: {
list: () => request<{ workspaces: unknown[] }>('/workspaces'),
get: (id: string) => request<{ workspace: unknown }>(`/workspaces/${id}`),
create: (data: unknown) =>
request<{ workspace: unknown }>('/workspaces', { method: 'POST', body: JSON.stringify(data) }),
},
search: (q: string) =>
request<{ results: unknown[] }>(`/search?q=${encodeURIComponent(q)}`),
auth: {
register: (data: unknown) =>
request<{ token: string; user: unknown }>('/auth/register', { method: 'POST', body: JSON.stringify(data) }),
login: (data: unknown) =>
request<{ token: string; user: unknown }>('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
me: () => request<{ user: unknown }>('/auth/me'),
},
};

View File

@ -0,0 +1,63 @@
import { useState, useEffect, useCallback } from 'react';
import { useNotesStore } from '../stores/notes';
import { BlockEditor } from '../editor/Editor';
import { api } from '../lib/api';
export function NoteEditor() {
const selectedNoteId = useNotesStore((s) => s.selectedNoteId);
const notes = useNotesStore((s) => s.notes);
const updateNote = useNotesStore((s) => s.updateNote);
const [title, setTitle] = useState('');
const [saving, setSaving] = useState(false);
const note = notes.find((n) => n.id === selectedNoteId);
useEffect(() => {
if (note) setTitle(note.title);
}, [note]);
const handleSave = useCallback(async () => {
if (!note) return;
setSaving(true);
try {
await api.nodes.update(note.id, { title });
} catch (err) {
console.error('Failed to save:', err);
} finally {
setSaving(false);
}
}, [note, title]);
if (!selectedNoteId || !note) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p className="text-lg">Select a note or create a new one</p>
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-3 px-6 py-3 border-b">
<input
type="text"
value={title}
onChange={(e) => { setTitle(e.target.value); updateNote(note.id, { title: e.target.value }); }}
onBlur={handleSave}
placeholder="Untitled"
className="flex-1 text-lg font-semibold bg-transparent focus:outline-none"
/>
<span className="text-xs text-muted-foreground">
{saving ? 'Saving...' : 'Saved'}
</span>
</div>
<div className="flex-1 overflow-y-auto">
<BlockEditor
content={note.content}
onChange={(content) => updateNote(note.id, { content })}
/>
</div>
</div>
);
}

View File

@ -0,0 +1,87 @@
import { Trash2, Search } from 'lucide-react';
import { useNotesStore } from '../stores/notes';
import { cn } from '@yetanother/utils';
function formatDate(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - d.getTime();
if (diff < 86400000) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (diff < 604800000) return d.toLocaleDateString([], { weekday: 'short' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
export function NoteList() {
const notes = useNotesStore((s) => s.notes);
const selectedNoteId = useNotesStore((s) => s.selectedNoteId);
const setSelectedNoteId = useNotesStore((s) => s.setSelectedNoteId);
const searchQuery = useNotesStore((s) => s.searchQuery);
const setSearchQuery = useNotesStore((s) => s.setSearchQuery);
const filtered = notes.filter((n) => {
if (n.status === 'deleted') return false;
if (!searchQuery) return true;
return n.title.toLowerCase().includes(searchQuery.toLowerCase());
});
return (
<div className="flex flex-col h-full">
<div className="p-3 border-b">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
<input
type="text"
placeholder="Search notes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{filtered.length === 0 && (
<div className="p-6 text-center text-sm text-muted-foreground">
{searchQuery ? 'No notes found' : 'No notes yet. Create one!'}
</div>
)}
{filtered.map((note) => (
<button
key={note.id}
type="button"
onClick={() => setSelectedNoteId(note.id)}
className={cn(
'w-full text-left px-4 py-3 border-b hover:bg-accent/50 transition-colors',
selectedNoteId === note.id && 'bg-accent',
)}
>
<div className="font-medium text-sm truncate">{note.title || 'Untitled'}</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">{formatDate(note.updatedAt)}</span>
{note.tags.length > 0 && (
<div className="flex gap-1">
{note.tags.slice(0, 2).map((tag) => (
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded-full bg-secondary text-secondary-foreground">
{tag}
</span>
))}
</div>
)}
</div>
</button>
))}
</div>
<div className="p-3 border-t mt-auto">
<button
type="button"
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Trash2 className="size-3.5" />
Trash
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,47 @@
import { Plus } from 'lucide-react';
import { useNotesStore } from '../stores/notes';
import { NoteList } from './NoteList';
import { NoteEditor } from './NoteEditor';
import { Button } from '@yetanother/ui';
export function NotesPage() {
const addNote = useNotesStore((s) => s.addNote);
const setSelectedNoteId = useNotesStore((s) => s.setSelectedNoteId);
const handleCreate = () => {
const now = new Date().toISOString();
const newNote = {
id: crypto.randomUUID(),
title: '',
type: 'note' as const,
content: { type: 'doc', content: [{ type: 'paragraph' }] },
plainText: '',
status: 'active' as const,
workspaceId: crypto.randomUUID(),
tags: [],
folderId: null,
createdAt: now,
updatedAt: now,
};
addNote(newNote);
setSelectedNoteId(newNote.id);
};
return (
<div className="flex h-full">
<div className="w-72 border-r flex flex-col bg-background">
<div className="p-3 border-b">
<Button onClick={handleCreate} size="sm" className="w-full gap-2">
<Plus className="size-4" />
New Note
</Button>
</div>
<NoteList />
</div>
<div className="flex-1">
<NoteEditor />
</div>
</div>
);
}

View File

@ -0,0 +1,68 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface Note {
id: string;
title: string;
type: 'note';
content: Record<string, unknown>;
plainText: string;
status: 'active' | 'archived' | 'deleted';
workspaceId: string;
tags: string[];
folderId: string | null;
createdAt: string;
updatedAt: string;
}
interface NoteFolder {
id: string;
name: string;
parentId: string | null;
}
interface NotesState {
notes: Note[];
folders: NoteFolder[];
selectedNoteId: string | null;
searchQuery: string;
loading: boolean;
setNotes: (notes: Note[]) => void;
addNote: (note: Note) => void;
updateNote: (id: string, updates: Partial<Note>) => void;
removeNote: (id: string) => void;
setSelectedNoteId: (id: string | null) => void;
setSearchQuery: (query: string) => void;
setLoading: (loading: boolean) => void;
setFolders: (folders: NoteFolder[]) => void;
}
export const useNotesStore = create<NotesState>()(
persist(
(set) => ({
notes: [],
folders: [],
selectedNoteId: null,
searchQuery: '',
loading: false,
setNotes: (notes) => set({ notes }),
addNote: (note) => set((state) => ({ notes: [note, ...state.notes] })),
updateNote: (id, updates) =>
set((state) => ({
notes: state.notes.map((n) => (n.id === id ? { ...n, ...updates } : n)),
})),
removeNote: (id) =>
set((state) => ({
notes: state.notes.filter((n) => n.id !== id),
})),
setSelectedNoteId: (id) => set({ selectedNoteId: id }),
setSearchQuery: (query) => set({ searchQuery: query }),
setLoading: (loading) => set({ loading }),
setFolders: (folders) => set({ folders }),
}),
{
name: 'yetanother-notes',
partialize: (state) => ({ notes: state.notes, folders: state.folders }),
},
),
);

File diff suppressed because it is too large Load Diff