Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion app/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
11 changes: 11 additions & 0 deletions app/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -91,6 +93,7 @@ export function App() {
}
>
<Route index element={<SpaceItems />} />
<Route path="notes" element={<SpaceNotes />} />
<Route path="tasks" element={<SpaceTasks />} />
<Route path="recurring" element={<SpaceSubscriptions />} />
<Route path="tags" element={<SpaceTags />} />
Expand All @@ -108,6 +111,14 @@ export function App() {
</Protected>
}
/>
<Route
path="notes/:id"
element={
<Protected>
<NotePage />
</Protected>
}
/>
<Route
path="events/:id"
element={
Expand Down
2 changes: 2 additions & 0 deletions app/frontend/src/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ interface AuthContextValue {
changePassword(proof: Proof, newPassword: string): Promise<void>;
deleteAccount(proof: Proof): Promise<void>;
updateUser(changes: Partial<Pick<User, 'displayName' | 'timeZone'>>): void;
accessToken(): string;
}

const AuthContext = createContext<AuthContextValue | null>(null);
Expand Down Expand Up @@ -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],
);
Expand Down
166 changes: 166 additions & 0 deletions app/frontend/src/components/NoteEditor.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div aria-label="Formatting" className="note-toolbar" role="toolbar">
{tools.map(([symbol, label, active, run]) => (
<button aria-label={label} aria-pressed={active} className="note-tool" key={label} onClick={run} title={label} type="button">
{symbol}
</button>
))}
</div>
);
}

function CollaborativeEditor({ connection, editable }: { connection: Connection; editable: boolean }) {
const auth = useAuth();
const show = useSnackbar();
const [people, setPeople] = useState<Person[]>([]);
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 <LinesSkeleton label="Loading the editor" lines={6} />;
const label =
status === 'disconnected' ? 'Offline. Reconnecting…' : status === 'connecting' ? 'Connecting…' : unsynced > 0 ? 'Saving…' : editable ? 'Saved' : 'View only';

return (
<div className="note-editor grid gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
{editable ? <Toolbar editor={editor} /> : <span />}
<div className="flex items-center gap-3">
{people.length > 0 && (
<div aria-label={`Also here: ${people.map((person) => person.name).join(', ')}`} className="flex -space-x-2" role="group">
{people.slice(0, 5).map((person) => (
<Avatar key={person.clientId} name={person.name} present />
))}
</div>
)}
<span className="mono-sm text-faint" role="status">
{label}
</span>
</div>
</div>
<EditorContent editor={editor} />
</div>
);
}

export default function NoteEditor({ editable, noteId }: { editable: boolean; noteId: string }) {
const auth = useAuth();
const [connection, setConnection] = useState<Connection>();
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 <LinesSkeleton label="Loading the editor" lines={6} />;
return <CollaborativeEditor connection={connection} editable={editable} />;
}
13 changes: 2 additions & 11 deletions app/frontend/src/components/SearchPalette.tsx
Original file line number Diff line number Diff line change
@@ -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 }[];
Expand Down Expand Up @@ -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',
})),
Expand Down
45 changes: 45 additions & 0 deletions app/frontend/src/notes.ts
Original file line number Diff line number Diff line change
@@ -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<string | null, Note[]>();
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';
Loading
Loading