diff --git a/app/frontend/package.json b/app/frontend/package.json index 1dfadb4..bfac044 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -25,7 +25,17 @@ "vitest": "5.0.1" }, "dependencies": { + "@hocuspocus/provider": "4.7.0", "@project-graphite/ui": "https://github.com/project-graphite/graphite-ui/releases/download/v0.3.0/project-graphite-ui-0.3.0.tgz", - "uqr": "0.1.3" + "@tiptap/core": "3.31.4", + "@tiptap/extension-collaboration": "3.31.4", + "@tiptap/extension-collaboration-caret": "3.31.4", + "@tiptap/extension-list": "3.31.4", + "@tiptap/extensions": "3.31.4", + "@tiptap/pm": "3.31.4", + "@tiptap/react": "3.31.4", + "@tiptap/starter-kit": "3.31.4", + "uqr": "0.1.3", + "yjs": "13.6.33" } } diff --git a/app/frontend/src/App.tsx b/app/frontend/src/App.tsx index e46c62d..2c6cf9c 100644 --- a/app/frontend/src/App.tsx +++ b/app/frontend/src/App.tsx @@ -15,6 +15,7 @@ import { RecurringPage } from './pages/RecurringPage'; import { LoginPage } from './pages/LoginPage'; import { MenuPage } from './pages/MenuPage'; import { NotFoundPage } from './pages/NotFoundPage'; +import { NotePage } from './pages/NotePage'; import { ForgotPasswordPage, ResetPasswordPage } from './pages/PasswordPages'; import { RegisterPage } from './pages/RegisterPage'; import { RsvpPage, SharePage } from './pages/RsvpPages'; @@ -29,6 +30,7 @@ import { SpaceActivity } from './pages/spaces/SpaceActivity'; import { SpaceItems } from './pages/spaces/SpaceItems'; import { SpaceLayout } from './pages/spaces/SpaceLayout'; import { SpaceMembers } from './pages/spaces/SpaceMembers'; +import { SpaceNotes } from './pages/spaces/SpaceNotes'; import { SpacesPage } from './pages/spaces/SpacesPage'; import { SpaceSubscriptions } from './pages/spaces/SpaceSubscriptions'; import { SpaceTags } from './pages/spaces/SpaceTags'; @@ -91,6 +93,7 @@ export function App() { } > } /> + } /> } /> } /> } /> @@ -108,6 +111,14 @@ export function App() { } /> + + + + } + /> ; deleteAccount(proof: Proof): Promise; updateUser(changes: Partial>): void; + accessToken(): string; } const AuthContext = createContext(null); @@ -180,6 +181,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const active = current.current; if (active) applySession({ ...active, user: { ...active.user, ...changes } }); }, + accessToken: () => current.current?.accessToken ?? '', }), [applySession, ready, request, session], ); diff --git a/app/frontend/src/components/NoteEditor.tsx b/app/frontend/src/components/NoteEditor.tsx new file mode 100644 index 0000000..2ca06aa --- /dev/null +++ b/app/frontend/src/components/NoteEditor.tsx @@ -0,0 +1,166 @@ +import { useEffect, useRef, useState } from 'react'; +import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider'; +import { Avatar, LinesSkeleton, useSnackbar } from '@project-graphite/ui'; +import Collaboration from '@tiptap/extension-collaboration'; +import CollaborationCaret from '@tiptap/extension-collaboration-caret'; +import { TaskItem, TaskList } from '@tiptap/extension-list'; +import { Placeholder } from '@tiptap/extensions'; +import { EditorContent, useEditor, useEditorState, type Editor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import * as Y from 'yjs'; +import { useAuth } from '../auth'; +import { realtimeUrl } from '../notes'; + +interface Connection { + document: Y.Doc; + provider: HocuspocusProvider; +} + +interface Person { + clientId: number; + name: string; + color: string; +} + +const colors = ['#e5736b', '#e0915a', '#d7b24a', '#5fbf7f', '#4fb3c4', '#6b8fe5', '#a07be5', '#e57bb5']; + +function colorFor(id: string) { + let hash = 0; + for (const character of id) hash = (hash * 31 + character.charCodeAt(0)) >>> 0; + return colors[hash % colors.length]!; +} + +function Toolbar({ editor }: { editor: Editor }) { + const state = useEditorState({ + editor, + selector: ({ editor: current }) => ({ + bold: current.isActive('bold'), + italic: current.isActive('italic'), + heading1: current.isActive('heading', { level: 1 }), + heading2: current.isActive('heading', { level: 2 }), + bullets: current.isActive('bulletList'), + numbers: current.isActive('orderedList'), + checklist: current.isActive('taskList'), + quote: current.isActive('blockquote'), + code: current.isActive('codeBlock'), + }), + }); + const tools: [string, string, boolean, () => void][] = [ + ['B', 'Bold', state.bold, () => editor.chain().focus().toggleBold().run()], + ['I', 'Italic', state.italic, () => editor.chain().focus().toggleItalic().run()], + ['H1', 'Heading', state.heading1, () => editor.chain().focus().toggleHeading({ level: 1 }).run()], + ['H2', 'Subheading', state.heading2, () => editor.chain().focus().toggleHeading({ level: 2 }).run()], + ['•', 'Bulleted list', state.bullets, () => editor.chain().focus().toggleBulletList().run()], + ['1.', 'Numbered list', state.numbers, () => editor.chain().focus().toggleOrderedList().run()], + ['☐', 'Checklist', state.checklist, () => editor.chain().focus().toggleTaskList().run()], + ['“', 'Quote', state.quote, () => editor.chain().focus().toggleBlockquote().run()], + ['', 'Code block', state.code, () => editor.chain().focus().toggleCodeBlock().run()], + ]; + return ( +
+ {tools.map(([symbol, label, active, run]) => ( + + ))} +
+ ); +} + +function CollaborativeEditor({ connection, editable }: { connection: Connection; editable: boolean }) { + const auth = useAuth(); + const show = useSnackbar(); + const [people, setPeople] = useState([]); + const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); + const [unsynced, setUnsynced] = useState(0); + const user = auth.user!; + + const editor = useEditor( + { + editable, + extensions: [ + StarterKit.configure({ undoRedo: false, link: { openOnClick: true, autolink: true, protocols: ['https', 'http', 'mailto'] } }), + TaskList, + TaskItem.configure({ nested: true }), + Placeholder.configure({ placeholder: editable ? 'Start writing…' : '' }), + Collaboration.configure({ document: connection.document }), + CollaborationCaret.configure({ provider: connection.provider, user: { name: user.displayName, color: colorFor(user.id) } }), + ], + editorProps: { attributes: { 'aria-label': 'Page content', class: 'note-content' } }, + }, + [connection], + ); + + useEffect(() => { + const { provider } = connection; + const onStatus = ({ status: next }: { status: 'connecting' | 'connected' | 'disconnected' }) => setStatus(next); + const onUnsynced = ({ number }: { number: number }) => setUnsynced(number); + const onAwareness = () => + setPeople( + [...(provider.awareness?.getStates().entries() ?? [])] + .filter(([clientId, state]) => clientId !== provider.awareness?.clientID && state.user) + .map(([clientId, state]) => ({ clientId, ...(state.user as { name: string; color: string }) })), + ); + const onStateless = ({ payload }: { payload: string }) => { + if (payload.includes('too-large')) show({ message: 'This page is too large to save. Split it into smaller pages.', tone: 'error' }); + }; + provider.on('status', onStatus); + provider.on('unsyncedChanges', onUnsynced); + provider.on('awarenessChange', onAwareness); + provider.on('stateless', onStateless); + return () => { + provider.off('status', onStatus); + provider.off('unsyncedChanges', onUnsynced); + provider.off('awarenessChange', onAwareness); + provider.off('stateless', onStateless); + }; + }, [connection, show]); + + if (!editor) return ; + const label = + status === 'disconnected' ? 'Offline. Reconnecting…' : status === 'connecting' ? 'Connecting…' : unsynced > 0 ? 'Saving…' : editable ? 'Saved' : 'View only'; + + return ( +
+
+ {editable ? : } +
+ {people.length > 0 && ( +
person.name).join(', ')}`} className="flex -space-x-2" role="group"> + {people.slice(0, 5).map((person) => ( + + ))} +
+ )} + + {label} + +
+
+ +
+ ); +} + +export default function NoteEditor({ editable, noteId }: { editable: boolean; noteId: string }) { + const auth = useAuth(); + const [connection, setConnection] = useState(); + const accessToken = useRef(auth.accessToken); + accessToken.current = auth.accessToken; + + useEffect(() => { + const document = new Y.Doc(); + const websocketProvider = new HocuspocusProviderWebsocket({ url: realtimeUrl() }); + const provider = new HocuspocusProvider({ name: noteId, document, websocketProvider, token: () => accessToken.current() }); + provider.attach(); + setConnection({ document, provider }); + return () => { + provider.destroy(); + websocketProvider.destroy(); + document.destroy(); + }; + }, [noteId]); + + if (!connection) return ; + return ; +} diff --git a/app/frontend/src/components/SearchPalette.tsx b/app/frontend/src/components/SearchPalette.tsx index 523f1aa..3e81cb5 100644 --- a/app/frontend/src/components/SearchPalette.tsx +++ b/app/frontend/src/components/SearchPalette.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { CommandPalette, isAbortError, type CommandItem } from '@project-graphite/ui'; import { useAuth } from '../auth'; -import { useSpaces, type Tag } from '../spaces'; +import { itemHref, useSpaces, type Tag } from '../spaces'; interface SearchResults { items: { id: string; spaceId: string; spaceName: string; kind: string; title: string; archived: boolean }[]; @@ -57,16 +57,7 @@ export function SearchPalette({ onClose }: { onClose: () => void }) { ...(results?.items ?? []).map((item) => ({ group: 'Items', hint: `${item.kind} · ${item.spaceName}${item.archived ? ' · archived' : ''}`, - href: - item.kind === 'task' - ? `/spaces/${item.spaceId}/tasks` - : item.kind === 'event' - ? `/events/${item.id}` - : item.kind === 'subscription' - ? `/spaces/${item.spaceId}/recurring` - : item.kind === 'list' - ? `/spaces/${item.spaceId}/tasks?list=${item.id}` - : `/spaces/${item.spaceId}${item.archived ? '?archived=true' : ''}`, + href: itemHref(item) ?? `/spaces/${item.spaceId}${item.archived ? '?archived=true' : ''}`, id: `item-${item.id}`, label: item.title || 'Untitled', })), diff --git a/app/frontend/src/notes.ts b/app/frontend/src/notes.ts new file mode 100644 index 0000000..9e86c28 --- /dev/null +++ b/app/frontend/src/notes.ts @@ -0,0 +1,45 @@ +export interface Note { + id: string; + spaceId: string; + title: string; + parentId: string | null; + position: number; + icon: string | null; + pinnedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface NoteDetails extends Note { + editable: boolean; + path: { id: string; title: string }[]; +} + +export interface NoteBranch { + note: Note; + depth: number; +} + +export function flattenTree(notes: Note[]) { + const children = new Map(); + const known = new Set(notes.map((note) => note.id)); + for (const note of notes) { + const parent = note.parentId && known.has(note.parentId) ? note.parentId : null; + children.set(parent, [...(children.get(parent) ?? []), note]); + } + const branches: NoteBranch[] = []; + const visit = (parent: string | null, depth: number) => { + for (const note of children.get(parent) ?? []) { + branches.push({ note, depth }); + visit(note.id, depth + 1); + } + }; + visit(null, 0); + return branches; +} + +export function realtimeUrl() { + return `${window.location.origin.replace(/^http/, 'ws')}/api/v1/realtime`; +} + +export const noteTitle = (note: { title: string }) => note.title || 'Untitled'; diff --git a/app/frontend/src/pages/NotePage.tsx b/app/frontend/src/pages/NotePage.tsx new file mode 100644 index 0000000..815d2a5 --- /dev/null +++ b/app/frontend/src/pages/NotePage.tsx @@ -0,0 +1,119 @@ +import { Fragment, lazy, Suspense } from 'react'; +import { Link, useNavigate, useParams } from 'react-router'; +import { EmptyState, Icon, LinesSkeleton, PageSkeleton } from '@project-graphite/ui'; +import { useAuth } from '../auth'; +import { LoadError } from '../components/LoadError'; +import { noteTitle, type Note, type NoteDetails } from '../notes'; +import { useSpaces } from '../spaces'; +import { useAction } from '../useAction'; +import { useResource } from '../useResource'; + +const NoteEditor = lazy(() => import('../components/NoteEditor')); + +export function NotePage() { + const { id = '' } = useParams(); + const auth = useAuth(); + const spaces = useSpaces(); + const navigate = useNavigate(); + const note = useResource(`/notes/${id}`, true); + const pages = useResource(note.data ? `/spaces/${note.data.spaceId}/notes` : null, true); + const renaming = useAction(); + const adding = useAction(); + + if (note.status === 404) { + return ( + +

It may have been deleted, or you are not a member of its space.

+
+ ); + } + if (note.error) return ; + if (!note.data) return ; + const details = note.data; + const space = spaces.data?.find((candidate) => candidate.id === details.spaceId); + const children = (pages.data ?? []).filter((page) => page.parentId === details.id); + + function rename(next: string) { + if (next === details.title) return; + void renaming.run(async () => { + const saved = await auth.request(`/notes/${details.id}`, { method: 'PATCH', body: JSON.stringify({ title: next }) }); + note.mutate((current) => ({ ...current, ...saved })); + pages.mutate((current) => current.map((page) => (page.id === saved.id ? saved : page))); + return ''; + }, 'Could not rename the page'); + } + + return ( +
+ + details.editable && rename(event.currentTarget.value.trim())} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + event.currentTarget.blur(); + } + }} + placeholder="Untitled" + readOnly={!details.editable} + /> + }> + + + {(children.length > 0 || details.editable) && ( +
+

Pages inside

+ {children.length > 0 && ( +
    + {children.map((child) => ( +
  • + + {child.icon ? `${child.icon} ` : ''} + {noteTitle(child)} + +
  • + ))} +
+ )} + {details.editable && ( + + )} +
+ )} +
+ ); +} diff --git a/app/frontend/src/pages/spaces/ItemList.tsx b/app/frontend/src/pages/spaces/ItemList.tsx index 0a750bb..add4df0 100644 --- a/app/frontend/src/pages/spaces/ItemList.tsx +++ b/app/frontend/src/pages/spaces/ItemList.tsx @@ -1,7 +1,8 @@ import type { ReactNode } from 'react'; +import { Link } from 'react-router'; import { TagChip } from '@project-graphite/ui'; import { FilePreview, hasPreview } from '../../files'; -import type { Item } from '../../spaces'; +import { itemHref, type Item } from '../../spaces'; export function ItemList({ actions, @@ -18,7 +19,15 @@ export function ItemList({
  • {hasPreview(item) && }
    -

    {item.title || 'Untitled'}

    +

    + {!item.trashedAt && itemHref(item) ? ( + + {item.title || 'Untitled'} + + ) : ( + item.title || 'Untitled' + )} +

    {item.kind} · {detail(item)}

    diff --git a/app/frontend/src/pages/spaces/SpaceLayout.tsx b/app/frontend/src/pages/spaces/SpaceLayout.tsx index 60d3518..5a83754 100644 --- a/app/frontend/src/pages/spaces/SpaceLayout.tsx +++ b/app/frontend/src/pages/spaces/SpaceLayout.tsx @@ -27,6 +27,7 @@ export function SpaceLayout() { const base = `/spaces/${space.id}`; const tabs = [ [base, 'Items'], + [`${base}/notes`, 'Notes'], [`${base}/tasks`, 'Tasks'], [`${base}/recurring`, 'Recurring'], [`${base}/tags`, 'Tags'], diff --git a/app/frontend/src/pages/spaces/SpaceNotes.tsx b/app/frontend/src/pages/spaces/SpaceNotes.tsx new file mode 100644 index 0000000..119a62f --- /dev/null +++ b/app/frontend/src/pages/spaces/SpaceNotes.tsx @@ -0,0 +1,64 @@ +import { Link, useNavigate } from 'react-router'; +import { EmptyState, Icon, ListSkeleton } from '@project-graphite/ui'; +import { useAuth } from '../../auth'; +import { LoadError } from '../../components/LoadError'; +import { flattenTree, noteTitle, type Note } from '../../notes'; +import { useAction } from '../../useAction'; +import { useResource } from '../../useResource'; +import { useSpace } from './SpaceLayout'; + +export function SpaceNotes() { + const auth = useAuth(); + const space = useSpace(); + const navigate = useNavigate(); + const notes = useResource(`/spaces/${space.id}/notes`, true); + const creating = useAction(); + const editable = space.role !== 'viewer'; + + if (notes.error) return ; + if (!notes.data) return ; + const branches = flattenTree(notes.data); + + return ( +
    + {editable && ( + + )} + {branches.length === 0 ? ( + + ) : ( +
      + {branches.map(({ note, depth }) => ( +
    • + + + {noteTitle(note)} + {note.pinnedAt && Pinned} + +
    • + ))} +
    + )} +
    + ); +} diff --git a/app/frontend/src/spaces.tsx b/app/frontend/src/spaces.tsx index 213cced..e95026c 100644 --- a/app/frontend/src/spaces.tsx +++ b/app/frontend/src/spaces.tsx @@ -53,6 +53,15 @@ export async function addJoinedSpace( spaces.mutate((current) => [...current.filter((shown) => shown.id !== space.id), space]); } +export function itemHref(item: { id: string; kind: string; spaceId: string }) { + if (item.kind === 'note') return `/notes/${item.id}`; + if (item.kind === 'event') return `/events/${item.id}`; + if (item.kind === 'task') return `/spaces/${item.spaceId}/tasks`; + if (item.kind === 'list') return `/spaces/${item.spaceId}/tasks?list=${item.id}`; + if (item.kind === 'subscription') return `/spaces/${item.spaceId}/recurring`; + return null; +} + export function SpaceDot({ color }: { color: TagColor }) { return