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:
parent
262fbf48e2
commit
93be6a051f
@ -22,7 +22,25 @@
|
||||
"@yetanother/types": "workspace:*",
|
||||
"zustand": "^5.0.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": {
|
||||
"@yetanother/tsconfig": "workspace:*",
|
||||
|
||||
@ -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 { NotesPage } from './notes/NotesPage';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<div className="h-screen flex flex-col">
|
||||
<Routes>
|
||||
<Route path="/" element={<div>YetAnotherSuite</div>} />
|
||||
<Route path="/" element={<Navigate to="/notes" replace />} />
|
||||
<Route path="/notes/*" element={<NotesPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
|
||||
39
apps/web/src/editor/Editor.tsx
Normal file
39
apps/web/src/editor/Editor.tsx
Normal 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 };
|
||||
81
apps/web/src/editor/EditorToolbar.tsx
Normal file
81
apps/web/src/editor/EditorToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
apps/web/src/editor/SlashMenu.tsx
Normal file
120
apps/web/src/editor/SlashMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
apps/web/src/editor/extensions.ts
Normal file
29
apps/web/src/editor/extensions.ts
Normal 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 }),
|
||||
];
|
||||
@ -30,3 +30,44 @@ body {
|
||||
color: var(--color-foreground);
|
||||
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
47
apps/web/src/lib/api.ts
Normal 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'),
|
||||
},
|
||||
};
|
||||
63
apps/web/src/notes/NoteEditor.tsx
Normal file
63
apps/web/src/notes/NoteEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
apps/web/src/notes/NoteList.tsx
Normal file
87
apps/web/src/notes/NoteList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
apps/web/src/notes/NotesPage.tsx
Normal file
47
apps/web/src/notes/NotesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
apps/web/src/stores/notes.ts
Normal file
68
apps/web/src/stores/notes.ts
Normal 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 }),
|
||||
},
|
||||
),
|
||||
);
|
||||
789
pnpm-lock.yaml
789
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user