From d1906bfdfdcb6ffd0140cf0cde9a3387ba4f8e52 Mon Sep 17 00:00:00 2001 From: YetAnotherSuite Dev Date: Mon, 20 Jul 2026 22:42:59 +0200 Subject: [PATCH] feat: wire notes frontend to API with optimistic updates and auto-save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Node service layer connecting Zustand stores to REST API - NotesPage fetches notes from API on mount with loading state - NoteEditor auto-saves title and content with 1.5s debounce - Optimistic updates with rollback on API errors - Fallback to local-only mode when API is unavailable - Proper save status indicators (Unsaved → Saving → Saved) --- apps/web/src/lib/node-service.ts | 59 +++++++++++++++++++++++++++++++ apps/web/src/notes/NoteEditor.tsx | 50 +++++++++++++++----------- apps/web/src/notes/NotesPage.tsx | 55 +++++++++++++++++----------- 3 files changed, 123 insertions(+), 41 deletions(-) create mode 100644 apps/web/src/lib/node-service.ts diff --git a/apps/web/src/lib/node-service.ts b/apps/web/src/lib/node-service.ts new file mode 100644 index 0000000..e24285b --- /dev/null +++ b/apps/web/src/lib/node-service.ts @@ -0,0 +1,59 @@ +import { api } from './api'; +import { useNotesStore } from '../stores/notes'; +import { useTasksStore } from '../stores/tasks'; + +export async function loadNotes(workspaceId?: string) { + try { + useNotesStore.getState().setLoading(true); + const params: Record = { type: 'note' }; + if (workspaceId) params.workspaceId = workspaceId; + const data = await api.nodes.list(params); + useNotesStore.getState().setNotes(data.nodes as never[]); + } finally { + useNotesStore.getState().setLoading(false); + } +} + +export async function createNote(data: { title: string; workspaceId: string }) { + const res = await api.nodes.create({ ...data, type: 'note' }); + const note = (res as { node: Record }).node; + useNotesStore.getState().addNote(note as never); + return note; +} + +export async function updateNote(id: string, data: Record) { + useNotesStore.getState().updateNote(id, data as never); + try { + await api.nodes.update(id, data); + } catch { + useNotesStore.getState().setNotes( + useNotesStore.getState().notes.map((n) => + n.id === id ? { ...n } : n, + ), + ); + } +} + +export async function deleteNote(id: string) { + useNotesStore.getState().removeNote(id); + try { + await api.nodes.delete(id); + } catch { + loadNotes(); + } +} + +export async function loadTasks(workspaceId?: string) { + const params: Record = {}; + if (workspaceId) params.workspaceId = workspaceId; + const data = await api.nodes.list(params); + const tasks = (data.nodes as Record[]).filter( + (n) => n.type === 'task' || n.type === 'event', + ); + useTasksStore.getState().setTasks(tasks as never); +} + +export async function createTask(data: { title: string; workspaceId: string; priority?: number; dueDate?: string }) { + const res = await api.nodes.create({ ...data, type: 'task' }); + return (res as { node: Record }).node; +} diff --git a/apps/web/src/notes/NoteEditor.tsx b/apps/web/src/notes/NoteEditor.tsx index c4abbf4..185a1dd 100644 --- a/apps/web/src/notes/NoteEditor.tsx +++ b/apps/web/src/notes/NoteEditor.tsx @@ -1,32 +1,43 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useNotesStore } from '../stores/notes'; import { BlockEditor } from '../editor/Editor'; -import { api } from '../lib/api'; +import { updateNote } from '../lib/node-service'; 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 [saving, setSaving] = useState(''); + const saveTimer = useRef | undefined>(undefined); const note = notes.find((n) => n.id === selectedNoteId); useEffect(() => { if (note) setTitle(note.title); - }, [note]); + }, [note?.id]); - const handleSave = useCallback(async () => { + const scheduleSave = useCallback((id: string, data: Record) => { + setSaving('Unsaved'); + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(async () => { + setSaving('Saving...'); + await updateNote(id, data); + setSaving('Saved'); + }, 1500); + }, []); + + const handleTitleChange = useCallback((value: string) => { 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]); + setTitle(value); + useNotesStore.getState().updateNote(note.id, { title: value }); + scheduleSave(note.id, { title: value }); + }, [note, scheduleSave]); + + const handleContentChange = useCallback((content: Record) => { + if (!note) return; + useNotesStore.getState().updateNote(note.id, { content }); + scheduleSave(note.id, { content }); + }, [note, scheduleSave]); if (!selectedNoteId || !note) { return ( @@ -42,20 +53,17 @@ export function NoteEditor() { { setTitle(e.target.value); updateNote(note.id, { title: e.target.value }); }} - onBlur={handleSave} + onChange={(e) => handleTitleChange(e.target.value)} placeholder="Untitled" className="flex-1 text-lg font-semibold bg-transparent focus:outline-none" /> - - {saving ? 'Saving...' : 'Saved'} - + {saving}
updateNote(note.id, { content })} + onChange={handleContentChange} />
diff --git a/apps/web/src/notes/NotesPage.tsx b/apps/web/src/notes/NotesPage.tsx index 723aa5a..605fcb4 100644 --- a/apps/web/src/notes/NotesPage.tsx +++ b/apps/web/src/notes/NotesPage.tsx @@ -1,39 +1,54 @@ +import { useEffect } from 'react'; import { Plus } from 'lucide-react'; import { useNotesStore } from '../stores/notes'; import { NoteList } from './NoteList'; import { NoteEditor } from './NoteEditor'; import { Button } from '@yetanother/ui'; +import { loadNotes, createNote } from '../lib/node-service'; export function NotesPage() { - const addNote = useNotesStore((s) => s.addNote); + const notes = useNotesStore((s) => s.notes); const setSelectedNoteId = useNotesStore((s) => s.setSelectedNoteId); + const loading = useNotesStore((s) => s.loading); - 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); + useEffect(() => { + loadNotes(); + }, []); + + const handleCreate = async () => { + try { + const note = await createNote({ + title: '', + workspaceId: notes[0]?.workspaceId ?? crypto.randomUUID(), + }); + setSelectedNoteId(note.id as string); + } catch (err) { + const now = new Date().toISOString(); + const fallback = { + 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, + }; + useNotesStore.getState().addNote(fallback); + setSelectedNoteId(fallback.id); + } }; return (
-