diff --git a/.agents/skills/liveblocks-best-practices/SKILL.md b/.agents/skills/liveblocks-best-practices/SKILL.md new file mode 100644 index 0000000..1e36161 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/SKILL.md @@ -0,0 +1,204 @@ +--- +name: "liveblocks-best-practices" +description: "Use this skill when building, debugging, or answering questions + about Liveblocks. Liveblocks gives you the building blocks and infrastructure + to enable people and AI to work together inside your app, powering realtime + collaboration. + + Liveblocks features include collaboration, rooms, organizations, workspaces, + comments, composer, threads, notifications, multiplayer, conflict resolution, + realtime presence, avatar stacks, AI collaborators, AI agents, text editors, + Tiptap, BlockNote, Lexical, React Flow, Chat SDK. + + Common components include AiChat, Thread, InboxNotification, Composer, Toolbar + (for Lexical Tiptap), FloatingToolbar, FloatingComposer, FloatingThreads, + AnchoredThreads. + + Common hooks include useThreads, useStorage, useMutation, useOthers, + useInboxNotifications, useAiChats. + + Common issues are related to authentication (ID tokens vs access tokens), + permissions, room limits, connection errors, user info." +license: "Apache License 2.0" +metadata: + author: "liveblocks" + version: "1.0.0" +--- + +# Liveblocks best practices + +## When to use this skill + +Use this skill when implementing features using any Liveblocks packages, for +example [`@liveblocks/react`](https://www.npmjs.com/package/@liveblocks/react) +or [`@liveblocks/node`](https://www.npmjs.com/package/@liveblocks/node). This +could be related to features like realtime multiplayer, commenting, +notifications, cursors, avatar stacks, AI, and more. This also includes +technologies like Liveblocks Storage, Yjs, Tiptap, BlockNote, Lexical, React +Flow, tldraw, and more. + +## Quick reference + +A number of reference files are available in the `references/` directory. You +must read the list below, and load relevant files as needed. For example, +`add-user-information` is available in the `references/add-user-information.md` +file. + +## References + +- `add-user-information`: Add user information to your application, such as + name, avatar, color, and more. Users will no longer be anonymous. Useful for + displaying user info in comments, comments mention suggestions, notifications, + cursors, avatar stacks. +- `ai-as-a-collaborator`: Use agentic workflows to allow AI to collaborate like + a human inside your Liveblocks application. +- `ai-chats-with-tools-knowledge-components`: Set up and troubleshoot chats with + RAG, knowledge, custom components, tool calling, custom models. Different to + AI as a collaborator, as it is NOT workflow based. +- `auth-endpoint-callback`: Use a callback function to authenticate users, + instead of passing a string to `authEndpoint`. Useful for passing custom + headers to your back end, and for preventing automatic reconnection when the + token is invalid. +- `authenticating-with-access-tokens`: Alternative method for authenticating + users with their ID and info, best for very simple permissions. +- `authenticating-with-id-tokens`: Recommended method for authenticating users + with their ID and info, best for complex permissions. +- `avoid-hitting-user-limit-in-rooms`: How to avoid rooms filling up with users. +- `compartmentalize-resources-with-organizations`: Use organizations to create + workspaces/tenants and compartmentalize resources, such as rooms, + notifications, comment threads, and more. You can filter by organization when + listing rooms and more. +- `create-custom-comment-composer`: Build your own commenting composer (advanced + use cases only). +- `create-custom-realtime-multiplayer`: Use Liveblocks Storage to build fully + custom conflict-resolved multiplayer apps, like Figma, Pitch, Spline. Helpful + for one-off realtime features too, like simple properties in a document. +- `create-custom-text-editor-toolbar`: Set up a styled toolbar, static or + floating next to your selection, in Tiptap and Lexical. +- `create-rooms-manually`: Always create rooms yourself in production + applications, setting permissions, organization, metadata. +- `customize-thread-components`: Use slots to customize comments inside of + threads, and their different parts. +- `dark-mode-styles`: You can import CSS styling for dark mode themes. +- `develop-and-test-locally`: Set up a local dev server, and use it for + Continuous Integration (CI) and End-to-End (E2E) testing. Connect to your app + not online. +- `devtools-extension`: Inspect Liveblocks Storage, Yjs, presence, events, and + connected users with DevTools extensions for Chrome, Firefox, Edge. +- `edit-component-text-strings`: Override strings in default components, such as + button values, tooltip text, error text, more. Also helpful for setting other + languages. +- `exhaustive-deps-with-usemutation`: Prevent and fix stale-closure bugs with + `useMutation` by configuring the `react-hooks/exhaustive-deps` ESLint rule. +- `handling-connection-errors`: Handle problems joining rooms because of + permissions, authentication, changed room IDs. +- `handling-full-rooms`: Handle problems caused by joining full rooms. +- `handling-hook-and-component-errors`: Handle errors caused by hooks and + pre-built components. +- `handling-unstable-connections`: Implement fallbacks and error messages when + users have poor network conditions. +- `log-out-of-liveblocks`: Rarely useful, but helpful in specific SPA situations + where you cannot navigate the page to log out. +- `multiplayer-react-flow`: Use the Liveblocks React Flow package to create + multiplayer diagrams with cursors and more. +- `multiple-text-editors`: Add multiple Tiptap or BlockNote editors to the same + page. Optionally use Storage to hold their field IDs. +- `offline-support-in-text-editors`: Give your text editors the illusion of a + quicker load time. +- `override-css-variables`: Add custom styles to the default components by + overriding Liveblocks CSS variables. +- `performant-others-and-presence`: Prevent unnecessary presence renders by + using `useOtherConnectionIds`, `useOthersMapped`, `useOther`, and `shallow` to + update components only when necessary. +- `performant-storage-with-selectors`: Prevent unnecessary storage renders by + using `useStorage` selectors and `shallow`. +- `prevent-unsaved-changes-being-lost`: Stop losing unsynched or unsaved + changes. +- `primitive-component-parts`: Use primitives to create custom components or to + merge components from your design system into Liveblocks UI. +- `remove-liveblocks-branding`: A Liveblocks logo badge appears in the bottom + right of the screen, this is how to remove it. +- `rendering-error-components`: Use `ErrorBoundary` to structure your app with + error fallbacks. +- `rendering-loading-components`: Use `ClientSideSuspense` to performantly + structure your app with loading skeletons and spinners. +- `send-comment-notification-emails`: Send email notifications to users when + they have unread comments. These comments are sent via webhooks, and are + triggered when a user is mentioned, or has participated in a thread. +- `smoother-realtime-updates`: Make presence and storage run at a higher frame + rate, appearing more smooth. Using this option, updates can be received more + frequently. +- `suspense-vs-regular-hooks`: You must read this when using any Liveblocks + hooks. React suspense uses `ErrorBoundary` and `ClientSideSuspense` + components. Regular hooks use `{ error, isLoading }` values. +- `tiptap-best-practices`: Server-side rendering, starter kit extensions, + initial content, unsaved changes, more. +- `trigger-custom-notifications`: Trigger any kind of notification in your + inbox, even those unrelated to commenting. +- `type-liveblocks-correctly`: Always use TypeScript to type Liveblocks where + available. Presence, others, user info, storage, metadata, room info, + notifications activities, can all be automatically typed. +- `url-params-in-room-id`: Use URL params in room IDs to create rooms, and + incorporate document titles in the URL. +- `utility-components`: Import a ready-made emoji picker, or human-readable + timestamps, durations, file sizes. +- `yjs-best-practices`: YKeyValue, subdocuments, V2 encoding, double imports, + getYjsProviderForRoom. NOT relevant if you're using Tiptap, BlockNote, or + Lexical. +- `z-index-issues`: Fix z-index problems by targeting portaled elements. + +## Note + +Some files link to markdown files in the Liveblocks docs, for example: + +``` +https://liveblocks.io/docs/concepts.md +``` + +When linking users to these pages, remove `.md` from the link, so they can view +the full content: + +``` +https://liveblocks.io/docs/concepts +``` + +## Liveblocks packages + +Always check if we provide a package for your technology. For example, if you're +using React Flow, you should use **`@liveblocks/react-flow`**. + +- **`@liveblocks/client`**: JavaScript client. Can use with any framework, e.g. + Vue, Svelte, vanilla JS. +- **`@liveblocks/react`**: React client. Contains hooks and components.. +- **`@liveblocks/react-ui`**: Pre-built React UI components and primitives. + `@liveblocks/react` for data and hooks. +- **`@liveblocks/react-tiptap`**: Collaborative Tiptap integration for React. +- **`@liveblocks/react-blocknote`**: Collaborative BlockNote integration for + React. +- **`@liveblocks/react-lexical`**: Collaborative Lexical integration for React. +- **`@liveblocks/node-lexical`**: Server-side Lexical utilities for Node.js. +- **`@liveblocks/node-prosemirror`**: Server-side ProseMirror utilities For + Node.js. Also works with Tiptap and BlockNote. +- **`@liveblocks/react-flow`**: Multiplayer React Flow. +- **`@liveblocks/yjs`**: Yjs provider backed by Liveblocks rooms. +- **`@liveblocks/redux`**: Sync Liveblocks room state with a Redux store. +- **`@liveblocks/zustand`**: Sync Liveblocks room state with Zustand. +- **`@liveblocks/node`**: Node.js server SDK. Use for auth and lots more. +- **`@liveblocks/emails`**: Build notification emails more easily. +- **`@liveblocks/chat-sdk-adapter`**: Make multi-platform (Liveblocks, Slack, + Discord, etc.) chat bots. +- **Python SDK**: Python server SDK. Use for auth and lots more. +- **REST API**: HTTP API. Use for auth and lots more. + +### If a technology is not listed here + +If the users want to set up a new technology, first check the following +resources to see if a package exists: + +- [llms.txt](https://liveblocks.io/llms.txt) +- [Documentation](https://liveblocks.io/docs) +- [Examples](https://liveblocks.io/examples) + +## Learn more + +Learn more in [Liveblocks docs](https://liveblocks.io/docs). diff --git a/.agents/skills/liveblocks-best-practices/references/_template.md b/.agents/skills/liveblocks-best-practices/references/_template.md new file mode 100644 index 0000000..593ae76 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/_template.md @@ -0,0 +1,23 @@ +--- +title: Name of the reference +--- + +# Name of the reference + +Explanation of the reference, a paragraph or two. If you mention an API then link to +it, e.g. +[`getOrCreateRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-or-create-rooms-roomId). + +```tsx +// Code snippet explaning it +``` + +Recommendations on other references to read: `my-other-reference-file`. + +## Another section if more info is needed (optional) + +A further paragraph or two if needed. + +```tsx +// Code snippet if needed +``` diff --git a/.agents/skills/liveblocks-best-practices/references/add-user-information.md b/.agents/skills/liveblocks-best-practices/references/add-user-information.md new file mode 100644 index 0000000..226fdb4 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/add-user-information.md @@ -0,0 +1,178 @@ +--- +title: "Add user information" +--- + +# Add user information + +Liveblocks Comments, Notifications, and other features require authentication +and resolvers to show user info. + +## Authenticate users + +First authenticate your users in an API endpoint. This is using ID tokens. +`name` and `color` are used in text editors to display the user's caret. Custom +properties can be accessed with `useSelf` or `useOthers`on the front end. These +properties are NOT used in comment threads, notifications, etc. + +```ts +const { status, body } = await liveblocks.identifyUser( + { + userId: "marc@example.com", + + // Optional + // organizationId: "org-id", + // groupIds: ["group-id-1", "group-id-2"], + }, + { + userInfo: { + name: "Marc", + color: "#00ff00", + + // Your custom metadata + // ... + }, + } +); +``` + +```ts file="liveblocks.config.ts" +declare global { + interface Liveblocks { + UserMeta: { + id: string; + + info: { + name: string; + color: string; + + // Your custom metadata + // ... + }; + }; + } +} +``` + +See the `authenticating-with-id-tokens` reference for more information on this +recommedned authentication method. An alternative is to use access tokens, see +the `authenticating-with-access-tokens` reference for more information. + +## Resolving users + +To show user info in +[`Thread`](https://liveblocks.io/docs/api-reference/liveblocks-react-ui#Thread), +[`InboxNotification`](https://liveblocks.io/docs/api-reference/liveblocks-react-ui#InboxNotification), +[`Cursors`](https://liveblocks.io/docs/api-reference/liveblocks-react-ui#Cursors), +and +[`AvatarStack`](https://liveblocks.io/docs/api-reference/liveblocks-react-ui#AvatarStack), +you must use +[`resolveUsers`](https://liveblocks.io/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers) +in `LiveblocksProvider`. + +```tsx +import { LiveblocksProvider } from "@liveblocks/react/suspense"; + +function App() { + return ( + { + // ["marc@example.com", "nimesh@example.com"]; + console.log(userIds); + + return [ + { name: "Marc", avatar: "https://example.com/marc.png" }, + { name: "Nimesh", avatar: "https://example.com/nimesh.png" }, + ]; + }} + + // Other props + // ... + > + {/* children */} + + ); +} +``` + +Users must be return in the same order they are passed. The array must be the +same length as the `userIds` array. You can also return custom information, for +example, a user’s `color`: + +```tsx +import { LiveblocksProvider } from "@liveblocks/react/suspense"; + +function App() { + return ( + { + // ["marc@example.com"]; + console.log(userIds); + + return [ + { + name: "Marc", + avatar: "https://example.com/marc.png", + color: "purple", + }, + ]; + }} + + // Other props + // ... + > + {/* children */} + + ); +} +``` + +In a real-world example, you would fetch users from your back end: + +```tsx + { + // Get users from your back end + const users = await __fetchUsers__(userIds); + + // Return a list of users + return users; + }} + + // ... +/> +``` + +## Resolving mention suggestions + +In commenting composers, you can mention other users (e.g. `@Marc`). Resolve +these mentions with +[`resolveMentionSuggestions`](https://liveblocks.io/docs/api-reference/liveblocks-react#LiveblocksProviderResolveMentionSuggestions) +in `LiveblocksProvider`. + +```tsx +import { LiveblocksProvider } from "@liveblocks/react/suspense"; + +function App() { + return ( + { + const workspaceUsers = await __getWorkspaceUsersFromDB__(roomId); + + if (!text) { + // Show all workspace users by default + return __getUserIds__(workspaceUsers); + } else { + // Show users that match the current search (define your own logic for this, e.g. fuzzy search, ) + const matchingUsers = __findUsers__(workspaceUsers, text); + return __getUserIds__(matchingUsers); + } + }} + + // Other props + // ... + > + {/* children */} + + ); +} +``` diff --git a/.agents/skills/liveblocks-best-practices/references/ai-as-a-collaborator.md b/.agents/skills/liveblocks-best-practices/references/ai-as-a-collaborator.md new file mode 100644 index 0000000..34582c1 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/ai-as-a-collaborator.md @@ -0,0 +1,9 @@ +--- +title: "AI as a collaborator" +--- + +# AI as a collaborator + +Set up AI as a collaborator in your app using agentic workflows. + +[Read this markdown page for up to date information](https://liveblocks.io/docs/guides/enabling-agentic-workflows-with-liveblocks.md). diff --git a/.agents/skills/liveblocks-best-practices/references/ai-chats-with-tools-knowledge-components.md b/.agents/skills/liveblocks-best-practices/references/ai-chats-with-tools-knowledge-components.md new file mode 100644 index 0000000..4dffb84 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/ai-chats-with-tools-knowledge-components.md @@ -0,0 +1,86 @@ +--- +title: "AI chats with tools, knowledge, components" +--- + +# AI chats with tools, knowledge, components + +Liveblocks AI Copilots provides customizable UI components that let your users +interact with AI in a way that feels native to your product. Unlike basic chat +widgets, Copilots are context-aware, collaborative, and capable of performing +real tasks—such as editing content, navigating your app, or answering +product-specific questions. They’re built for React developers, work with your +chosen LLM, and integrate directly into your existing UI using flexible APIs and +fully themeable components. + +The basic component is AI chat: + +```tsx +import { AiChat } from "@liveblocks/react-ui"; + +; +``` + +## Copilots + +AI Copilots provides a default copilot for testing, but you should define a +custom copilot in the [Liveblocks dashboard](https://liveblocks.io/dashboard) +for further development and production. This allows you to configure your AI +model, system prompt, back-end knowledge, and more. + +[Learn more](https://liveblocks.io/docs/ready-made-features/ai-agents/liveblocks-ai-copilots/copilots.md). + +## Default components + +The default components included in AI Copilots are a great way to start building +AI into your application. With these components you can render advanced AI +chats, that understand your application state, modify it with actions, and +render custom in-chat components. + +[Learn more](https://liveblocks.io/docs/ready-made-features/ai-agents/liveblocks-ai-copilots/default-components.md). + +## Hooks + +The AI Copilots React hooks allow you to fetch, create, and modify AI chats, +enabling you to build custom AI interfaces, even beyond our default components. +Chats are stored permanently and the infrastructure is handled for you. All +hooks work optimistically, meaning they update immediately, before the server +has synched. + +[Learn more](https://liveblocks.io/docs/ready-made-features/ai-agents/liveblocks-ai-copilots/hooks.md). + +## Knowledge + +Knowledge allows you to provide information to your AI so it can understand your +application’s state, content, and domain-specific information, making responses +more relevant and accurate. There are two different knowledge types—front-end +knowledge, ideal for adding small pieces of contextual information, and back-end +knowledge, designed for larger datasets such as entire websites and lengthy +PDFs. Additionally, you can enable web search, allowing your AI to query the +internet for information. + +[Learn more](https://liveblocks.io/docs/ready-made-features/ai-agents/liveblocks-ai-copilots/knowledge.md). + +## Tools + +Tools allow AI to make actions, modify your application state, interact with +your front-end, and render custom components within your AI chat. Use tools to +extend the capabilities of AI Copilots beyond simple text, allowing autonomous +and human-in-the-loop interactions. + +[Learn more](https://liveblocks.io/docs/ready-made-features/ai-agents/liveblocks-ai-copilots/tools.md). + +## Styling and customization + +Styling default components is enabled through a range of means, such as CSS +variables, class names, and more. It’s also possible to use overrides to modify +any strings used in the default components, which is especially helpful for +localization. + +[Learn more](https://liveblocks.io/docs/ready-made-features/ai-agents/liveblocks-ai-copilots/styling-and-customization.md). + +## Troubleshooting + +AI models are often unreliable and tricky to debug. Here are some common issues +you may encounter, and how to troubleshoot them. + +[Learn more](https://liveblocks.io/docs/ready-made-features/ai-agents/liveblocks-ai-copilots/troubleshooting.md). diff --git a/.agents/skills/liveblocks-best-practices/references/auth-endpoint-callback.md b/.agents/skills/liveblocks-best-practices/references/auth-endpoint-callback.md new file mode 100644 index 0000000..6c3eeca --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/auth-endpoint-callback.md @@ -0,0 +1,69 @@ +--- +title: "Auth endpoint callback" +--- + +# Auth endpoint callback + +Liveblocks recommends using an auth endpoint to authenticate users, usually like +this: + +```tsx +import { LiveblocksProvider } from "@liveblocks/react/suspense"; + +function App() { + return ( + + {/* children */} + + ); +} +``` + +However you can also define a callback function, and write your own fetch logic: + +```tsx +import { LiveblocksProvider } from "@liveblocks/react/suspense"; + +function App() { + return ( + { + const response = await fetch("/api/liveblocks-auth", { + method: "POST", + headers: { + Authentication: "", + "Content-Type": "application/json", + }, + // Don't forget to pass `room` down. Note that it + // can be undefined when using Notifications. + body: JSON.stringify({ room }), + }); + return await response.json(); + }} + > + {/* children */} + + ); +} +``` + +In your auth endpoint, a token is returned, see `authenticating-with-id-tokens` +for more information on setting this up. It will return a token in the following +format, which you must return, as we are with `await response.json()` in the +example above: + +``` +{ "token": "..." } +``` + +## Prevent reconnection + +If the returned token or data is invalid, Liveblocks will repeatedly retry the +callback, and try to reconnect. If you wish to prevent this, manually return a +token with the following format: + +``` +{ "error": "forbidden", "reason": "..." } +``` + +When Liveblocks sees `"error": "forbidden"`, it will no longer try to reconnect. diff --git a/.agents/skills/liveblocks-best-practices/references/authenticating-with-access-tokens.md b/.agents/skills/liveblocks-best-practices/references/authenticating-with-access-tokens.md new file mode 100644 index 0000000..f8eae6b --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/authenticating-with-access-tokens.md @@ -0,0 +1,78 @@ +--- +title: "Authenticating with Access Tokens" +--- + +## Authenticating with Access Tokens + +Access tokens is a simpler method for authentication (the recommended method is +ID tokens). Start this with +[`identifyUser`](https://liveblocks.io/docs/api-reference/liveblocks-node#access-tokens), +before returning the `body` and `status` from your API endpoint. + +```ts +import { Liveblocks } from "@liveblocks/node"; + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, +}); + +export async function POST(request: Request) { + // Get the current user from your database + const user = __getUserFromDB__(request); + + const session = liveblocks.prepareSession( + // Required, the current user's ID + user.id, + { + // Optional, used to provision room access on group level + // groupIds: ["design", "engineering"], + + // Optional, the organization the user belongs to + // organizationId: "acme-corp", + + // Optional, custom user metadata + userInfo: user.metadata, + } + ); + + // Use a naming pattern to allow access to rooms with wildcards + // Giving the user read access on their org, and write access on their group + session.allow(`${user.organization}:*`, session.READ_ACCESS); + session.allow(`${user.organization}:${user.group}:*`, session.FULL_ACCESS); + + // Authorize the user and return the result + const { status, body } = await session.authorize(); + return new Response(body, { status }); +} +``` + +## Naming pattern + +It's recommended to use a naming pattern for your rooms with access tokens, to +allow access to rooms with wildcards. + +Let's picture an organization in your product, Acme, set up using workspace +permissions. This customer has a number of group, and each group contains a +number of documents. In your application, each group and document has a unique +ID, and we can use these to create a naming pattern for your rooms. For example, +the Acme organization has a Product group (`product`) with two documents inside +(`6Dsw12`, `L2hr8p`). + +```diagram + Organization Room ID + │ │ + ┌─────▼────┐ ┌───────▼────────┐ + │ acme │ │ product:6Dsw1z │ + └──────────┘ └────┬───────┬───┘ + │ │ + Group Document +``` + +An example of a naming pattern would be to combine these IDs into a unique room +ID separating them with symbols, such as `:`. A room ID +following this pattern may look like `product:6Dsw1z`. The acme organization is +also added as an `organizationId`. + +## See also + +- [Authenticating with access Tokens](https://liveblocks.io/docs/authentication/access-token.md) diff --git a/.agents/skills/liveblocks-best-practices/references/authenticating-with-id-tokens.md b/.agents/skills/liveblocks-best-practices/references/authenticating-with-id-tokens.md new file mode 100644 index 0000000..bc2f752 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/authenticating-with-id-tokens.md @@ -0,0 +1,80 @@ +--- +title: "Authenticating with ID Tokens" +--- + +## Authenticating with ID Tokens + +The recommended authentication method for Liveblocks is ID tokens. Start this +with +[`identifyUser`](https://liveblocks.io/docs/api-reference/liveblocks-node#id-tokens), +before returning the `body` and `status` from your API endpoint. + +```ts +import { Liveblocks } from "@liveblocks/node"; + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, +}); + +export async function POST(request: Request) { + // Get the current user from your database + const user = __getUserFromDB__(request); + + // Identify the user and return the result + const { status, body } = await liveblocks.identifyUser( + { + // Required, the current user's ID + userId: user.id, + + // Groups the user belongs to, optional + // groupIds: ["design", "engineering"], + + // Optional, the organization the user belongs to + // organizationId: "acme-corp", + }, + // Optional, custom user metadata, available in React hooks + { userInfo: user.metadata } + ); + + return new Response(body, { status }); +} +``` + +ID token auth requires you to create rooms manually, and set permissions, for +example with +[`getOrCreateRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-or-create-rooms-roomId). +If you don't set permissions, the room will be private by default, and no one +will be able to join. You must set a form of permissions, either +`defaultAccesses`, `groupsAccesses`, or `usersAccesses`. + +```ts +const room = await liveblocks.getOrCreateRoom("my-room-id", { + // The default room permissions. `[]` for private, `["room:write"]` for public. + defaultAccesses: [], + + // Optional, the room's group ID permissions + groupsAccesses: { + design: ["room:write"], + engineering: ["room:presence:write", "room:read"], + }, + + // Optional, the room's user ID permissions + usersAccesses: { + "my-user-id": ["room:write"], + }, + + // Optional, custom metadata to attach to the room + // metadata: { + // myRoomType: "whiteboard", + // }, + + // Optional, create it on a specific organization + // organizationId: "acme-corp", +}); +``` + +Read the `create-rooms-manually` reference for more information. + +## See also + +- [Authenticating with ID Tokens](https://liveblocks.io/docs/authentication.md) diff --git a/.agents/skills/liveblocks-best-practices/references/avoid-hitting-user-limit-in-rooms.md b/.agents/skills/liveblocks-best-practices/references/avoid-hitting-user-limit-in-rooms.md new file mode 100644 index 0000000..3698921 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/avoid-hitting-user-limit-in-rooms.md @@ -0,0 +1,54 @@ +--- +title: "Avoid hitting user limit in rooms" +--- + +# Avoid hitting user limit in rooms + +Only a certain amount of users can join your room, and this limit is set by your +plan, for example 20 or 50. One way to avoid hitting this limit is to set +[`backgroundKeepAliveTimeout`](https://liveblocks.io/docs/api-reference/liveblocks-react#LiveblocksProviderBackgroundKeepAliveTimeout) +on `LiveblocksProvider`. This disconnects users that have not opened their +Liveblocks tab after a certain amount of time, helping you avoid hitting the +limit. + +```tsx +import { LiveblocksProvider } from "@liveblocks/react/suspense"; + +export function Providers() { + return ( + + {/* children */} + + ); +} +``` + +Users will connect seamlessly when they reopen the tab. + +## Render static documents + +Another option is to use the Node.js back end or REST APIs to render static +documents. This only works if users never need to interact with the document, so +it only works in specific use cases. + +Here's an example using Storage. + +```ts +import { Liveblocks } from "@liveblocks/node"; + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, +}); + +async function Page() { + const storage = await liveblocks.getStorageDocument("my-room-id", "json"); + + return +} +``` diff --git a/.agents/skills/liveblocks-best-practices/references/compartmentalize-resources-with-organizations.md b/.agents/skills/liveblocks-best-practices/references/compartmentalize-resources-with-organizations.md new file mode 100644 index 0000000..8771cf2 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/compartmentalize-resources-with-organizations.md @@ -0,0 +1,94 @@ +--- +title: "Compartmentalize resources with organizations" +--- + +# Compartmentalize resources with organizations + +Setting an `organizationId` on various resources will compartmentalize it to +that organization, which can be used like a workspace in your application. Here +are some examples, note that all are server-side API calls. + +```ts +const room = await liveblocks.createRoom("my-room-id", { + organizationId: "my-organization-id", +}); + +const { data: rooms, nextCursor } = await liveblocks.getRooms({ + organizationId: "my-organization-id", +}); + +await liveblocks.triggerInboxNotification({ + userId: "steven@example.com", + kind: "$fileUploaded", + subjectId: "my-file", + activityData: {}, + organizationId: "my-organization-id", +}); + +await liveblocks.deleteAllInboxNotifications({ + userId: "steven@example.com", + organizationId: "my-organization-id", +}); + +const { data, nextCursor } = await liveblocks.getUserRoomSubscriptionSettings({ + userId: "steven@example.com", + organizationId: "my-organization-id", +}); +``` + +When no `organizationId` is set, the `"default"` organization is used. + +## Adding a user to an organization + +When authorizing a user with Liveblocks, you can specify the organization they +belong to. Each user is only authorized for one organization at a time, meaning +they need to re-authenticate to access resources in another organization, such +as notifications. Organizations can be used with both ID Token and Access Token +authentication. + +### ID tokens + +When using ID tokens, you can set the `organizationId` when using +`identifyUser`. Tokens generated for a specific organization, will only allow +access to resources inside this organization, even if the user has access to +rooms in other organizations. + +```ts +const { body, status } = await liveblocks.identifyUser({ + userId: "olivier@example.com", + organizationId: "organization123", +}); + +// '{ token: "eyJga7..." }' +console.log(body); +``` + +### Access tokens + +When using access tokens, you can set the organizationId when you prepare a +session. Tokens generated for a specific organization, will only allow access to +resources inside this organization, even if the token has permissions to rooms +in other organizations. + +```ts +const session = liveblocks.prepareSession("olivier@example.com", { + organizationId: "organization123", +}); + +// Giving full access to one room +session.allow("Vu78Rt:design:9Hdu73", session.FULL_ACCESS); + +// Give full access to every room with an ID beginning with "Vu78Rt:product:" +session.allow("Vu78Rt:product:*", session.FULL_ACCESS); + +const { body, status } = await session.authorize(); +``` + +## Front-end + +After authenticating, the front-end will have access to resources that are part +of that organization, for example notifications with `useInboxNotifications`. + +## See also + +- [Organizations](https://liveblocks.io/docs/authentication/organizations.md) diff --git a/.agents/skills/liveblocks-best-practices/references/create-custom-comment-composer.md b/.agents/skills/liveblocks-best-practices/references/create-custom-comment-composer.md new file mode 100644 index 0000000..82df7fd --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/create-custom-comment-composer.md @@ -0,0 +1,44 @@ +--- +title: "Create custom comment composers" +--- + +# Create custom comment composer + +You can create a custom comment composer with +[`useComposer`](https://liveblocks.io/docs/api-reference/liveblocks-react-ui#useComposer). + +```tsx +import { Composer, useComposer } from "@liveblocks/react-ui/primitives"; +import { useCreateThread } from "@liveblocks/react/suspense"; + +function MyComposer() { + const createThread = useCreateThread(); + + return ( + { + const thread = createThread({ + body, + attachments, + metadata: {}, + }); + }} + > + + + ); +} + +function Editor() { + const { createMention } = useComposer(); + + return ( + <> + + + + ); +} +``` + +[More info](https://liveblocks.io/docs/api-reference/liveblocks-react-ui#Custom-composer-behavior). diff --git a/.agents/skills/liveblocks-best-practices/references/create-custom-realtime-multiplayer.md b/.agents/skills/liveblocks-best-practices/references/create-custom-realtime-multiplayer.md new file mode 100644 index 0000000..b846221 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/create-custom-realtime-multiplayer.md @@ -0,0 +1,19 @@ +--- +title: "Create custom realtime multiplayer" +--- + +# Create custom realtime multiplayer + +Liveblocks Storage is a realtime sync engine designed for multiplayer creative +tools such as Figma, Pitch, and Spline. LiveList, LiveMap, and LiveObject +conflict-free data types can be used to build all sorts of multiplayer tools. +Liveblocks permanently stores Storage data in each room, handling scaling and +maintenance for you. + +[Load this markdown and learn more](https://liveblocks.io/docs/ready-made-features/multiplayer/sync-engine/liveblocks-storage.md). + +## See also + +Guide users to here if they're interested: + +- [Interactive guide](https://liveblocks.io/docs/tutorial/react/getting-started/storage). diff --git a/.agents/skills/liveblocks-best-practices/references/create-custom-text-editor-toolbar.md b/.agents/skills/liveblocks-best-practices/references/create-custom-text-editor-toolbar.md new file mode 100644 index 0000000..c8410c7 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/create-custom-text-editor-toolbar.md @@ -0,0 +1,234 @@ +--- +title: "Create custom text editor toolbar" +--- + +# Create custom text editor toolbar + +Tiptap and Lexical allow you to create custom toolbars, with sty;es that match +the existing toolbar and Liveblocks components. This works the same way for +`Toolbar` and `FloatingToolbar`. `FloatingToolbar` floats below the current text +selection. + +```tsx +import { Toolbar } from "@liveblocks/react-tiptap"; +import { Icon } from "@liveblocks/react-ui"; +import { Editor } from "@tiptap/react"; + +function CustomToolbar({ editor }: { editor: Editor | null }) { + return ( + + + + } + shortcut="CMD-H" + onClick={() => console.log("help")} + /> + 🖊️} + active={editor?.isActive("highlight") ?? false} + onClick={() => editor?.chain().focus().toggleHighlight().run()} + disabled={!editor?.can().chain().focus().toggleHighlight().run()} + /> + + ); +} +``` + +## Buttons + +Buttons can be customized lots: + +```tsx +import { Toolbar } from "@liveblocks/react-tiptap"; +import { Icon } from "@liveblocks/react-ui"; + +// Button says "Question" + + +// Tooltip says "Question [⌘+Q]" + + +// Custom icon, replaces the name in the button +?} onClick={/* ... */} /> + +// Using a Liveblocks icon, replaces the name in the button +} onClick={/* ... */} /> + +// Passing children visually replaces the `name` and `icon` + + ? Ask a question + + +// Props are passed to the inner `button` + console.log("Hovered")} +/> +``` + +## Toggles + +Toggles too: + +```tsx +import { Toolbar } from "@liveblocks/react-tiptap"; +import { Icon } from "@liveblocks/react-ui"; + +// Button says "Highlight" + + +// Tooltip says "Highlight [⌘+H]" + + +// Custom icon, replaces the name in the button +🖊} + active={/* ... */} + onClick={/* ... */} +/> + +// Using a Liveblocks icon, replaces the name in the button +} + active={/* ... */} + onClick={/* ... */} +/> + +// Passing children visually replaces the `name` and `icon` + + 🖊️Highlight + + +// Props are passed to the inner `button` + console.log("Hovered")} +/> +``` + +## BlockSelector + +Also the `BlockSelector`: + +```tsx +import { Toolbar } from "@liveblocks/react-tiptap"; + + + [ + ...defaultItems, + { + name: "Code block", + icon:
❮ ❯
, // Optional + // label:
Code
, // Optional, overwrites `icon` + `name` + isActive: (editor) => editor.isActive("codeBlock"), + setActive: (editor) => + editor.chain().focus().clearNodes().toggleCodeBlock().run(), + }, + ]} + /> +
; +``` + +```tsx +import { Toolbar } from "@liveblocks/react-tiptap"; + + + defaultItems.map((item) => { + let label; + + if (item.name === "Text") { + label = Regular text; + } + + if (item.name === "Heading 1") { + label = ( + Heading 1 + ); + } + + if (item.name === "Heading 2") { + label = ( + Heading 2 + ); + } + + if (item.name === "Heading 3") { + label = ( + Heading 3 + ); + } + + if (item.name === "Blockquote") { + label = ( + + Blockquote + + ); + } + + return { + ...item, + label, + icon: null, // Hide all icons + }; + }) + } +/>; +``` + +Remember to export from `"@liveblocks/react-lexical"` for Lexical. Export and +use `{ FloatingToolbar }` if you'd like to modify the floating version. + +```tsx +import { FloatingToolbar, Toolbar } from "@liveblocks/react-lexical"; +import { Icon } from "@liveblocks/react-ui"; +import { Editor } from "@tiptap/react"; + +function CustomToolbar({ editor }: { editor: Editor | null }) { + return ( + + + + } + shortcut="CMD-H" + onClick={() => console.log("help")} + /> + 🖊️} + active={editor?.isActive("highlight") ?? false} + onClick={() => editor?.chain().focus().toggleHighlight().run()} + disabled={!editor?.can().chain().focus().toggleHighlight().run()} + /> + + ); +} +``` diff --git a/.agents/skills/liveblocks-best-practices/references/create-rooms-manually.md b/.agents/skills/liveblocks-best-practices/references/create-rooms-manually.md new file mode 100644 index 0000000..b42407d --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/create-rooms-manually.md @@ -0,0 +1,89 @@ +--- +title: "Create rooms manually" +--- + +# Create rooms manually + +Create Liveblocks rooms manually to control your own permissions and metadata. +It's recommended to use +[`getOrCreateRoom`](https://liveblocks.io/docs/api-reference/liveblocks-node#get-or-create-rooms-roomId) +for this and do standard +[error handling](https://liveblocks.io/docs/api-reference/liveblocks-node#error-handling). + +```tsx +import { Liveblocks } from "@liveblocks/node"; + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, +}); + +export function fetchRoom(roomId: string) { + let room; + + try { + room = await liveblocks.getOrCreateRoom(roomId, { + // The default room permissions. `[]` for private, `["room:write"]` for public. + defaultAccesses: [], + + // Optional, custom metadata to attach to the room + metadata: { + title: "Untitled", + }, + + // Optional, the room's group ID permissions + // groupsAccesses: { + // design: ["room:write"], + // engineering: ["room:presence:write", "room:read"], + // }, + + // Optional, the room's user ID permissions + // usersAccesses: { + // "my-user-id": ["room:write"], + // }, + + // Optional, create it on a specific organization + // organizationId: "acme-corp", + }); + } catch (error) { + if (error instanceof LiveblocksError) { + // Handle specific LiveblocksError cases + console.error( + `Error getting or creating room: ${error.status} - ${error.message}` + ); + switch ( + error.status + // Specific cases based on status codes + ) { + } + } else { + // Handle general errors + console.error(`Unexpected error: ${error.message}`); + } + return null; + } + + return room; +} +``` + +How you might use this in Next.js. + +```tsx file="app/document/[slug]/page.tsx" +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const room = await fetchRoom(slug); + + if (!room) { + return ; + } + + return ; +} +``` + +A `title` is never required, but it is another reason you may wish to fetch the +room beforehand. diff --git a/.agents/skills/liveblocks-best-practices/references/customize-thread-components.md b/.agents/skills/liveblocks-best-practices/references/customize-thread-components.md new file mode 100644 index 0000000..dd314dc --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/customize-thread-components.md @@ -0,0 +1,86 @@ +--- +title: "Customize thread components" +--- + +# Customize thread components + +You can deeply customize +[`Thread`](https://liveblocks.io/docs/api-reference/liveblocks-react-ui#Thread) +component by inserting your own UI into various slots. + +```tsx +import { Comment, Thread } from "@liveblocks/react-ui"; +import { ThreadData } from "@liveblocks/client"; + +// ✅ Keeps thread functionality +function CustomThread({ thread }: { thread: ThreadData }) { + return ( + ( + + +
+
+ } + author={ + + + Custom label + + } + date={ + + + {props.comment.editedAt && ( + Edited + )} + + } + additionalContent={ +
+ Content below the comment's body (above reactions and + attachments) +
+ } + {...props} + > + {({ children }) => ( +
+ {children} +
+ Content below the comment's content (including reactions and + attachments) +
+
+ )} +
+ ), + }} + /> + ); +} +``` + +Always prefer the method above. Don't customize your thread like this, as you +will losing basic `Thread` functionality, such as unread message status: + +```tsx +import { Comment } from "@liveblocks/react-ui"; +import { ThreadData } from "@liveblocks/client"; + +// ❌ Loses Thread functionality +function CustomThread({ thread }: { thread: ThreadData }) { + return ( + <> + {thread.comments.map((comment) => ( + + ))} + + ); +} +``` diff --git a/.agents/skills/liveblocks-best-practices/references/dark-mode-styles.md b/.agents/skills/liveblocks-best-practices/references/dark-mode-styles.md new file mode 100644 index 0000000..59bd67f --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/dark-mode-styles.md @@ -0,0 +1,42 @@ +--- +title: "Dark mode style" +--- + +# Dark mode styles + +To import dark mode styling, as well as the default styles, use the following +snippets: + +```tsx +import "@liveblocks/react-ui/styles.css"; + +// Dark mode using the system theme with `prefers-color-scheme` +import "@liveblocks/react-ui/styles/dark/media-query.css"; +``` + +There's an alternate dark mode you can use instead: + +```tsx +import "@liveblocks/react-ui/styles.css"; + +// Dark mode using `className="dark"`, `data-theme="dark"`, or `data-dark="true"` +import "@liveblocks/react-ui/styles/dark/attributes.css"; +``` + +## CSS imports are supported + +You can also import into CSS files. + +```css +@import "@liveblocks/react-ui/styles.css"; +@import "@liveblocks/react-ui/styles/dark/media-query.css"; +``` + +## Other libraries + +Remember that other Liveblocks libraries have _additional_ style sheets, so +don't remove them, for example Tiptap: + +```tsx +import "@liveblocks/react-tiptap/styles.css"; +``` diff --git a/.agents/skills/liveblocks-best-practices/references/develop-and-test-locally.md b/.agents/skills/liveblocks-best-practices/references/develop-and-test-locally.md new file mode 100644 index 0000000..fa9c1ca --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/develop-and-test-locally.md @@ -0,0 +1,26 @@ +--- +title: "Develop and test locally" +--- + +# Develop and test locally + +The Liveblocks dev server is a local server that lets you develop and test +multiplayer features without connecting to Liveblocks production servers. It is +built on our open-source `@liveblocks/server` package. + +It may not support all Liveblocks features, but fully supports Storage, Yjs, +text editors. Check the table for +[up to date info](https://liveblocks.io/docs/tools/dev-server.md). + +## Set up + +1. Install bun: `npm install -g bun` +2. Start the dev server: `npx liveblocks dev` +3. Copy the prompt from the terminal with "p" and paste it into your AI chat + (this makes all the code changes, modifying `baseUrl` and + `secret`/`publicApiKey` to the correct values). +4. Done. + +- [Up to date info](https://liveblocks.io/docs/tools/dev-server.md). +- [Set up CI testing](https://liveblocks.io/docs/guides/how-to-set-up-continuous-integration-ci-testing.md). +- [Set up E2E testing](vhttps://liveblocks.io/docs/guides/how-to-set-up-end-to-end-e2e-testing-with-playwright.md). diff --git a/.agents/skills/liveblocks-best-practices/references/devtools-extension.md b/.agents/skills/liveblocks-best-practices/references/devtools-extension.md new file mode 100644 index 0000000..899beb9 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/devtools-extension.md @@ -0,0 +1,18 @@ +--- +title: "DevTools extension" +--- + +# DevTools extension + +Our DevTools is a browser-based extension that integrates with Liveblocks and +your local development environment. This allows you to easily inspect, +visualize, and troubleshoot your collaborative online experiences. It supports +Liveblocks Storage, Yjs, text editors, presence, events, and connected users. + +[Learn more](https://liveblocks.io/docs/tools/devtools.md). + +## Install + +- [Chrome](https://chromewebstore.google.com/detail/liveblocks-devtools/iiagocfmmhknpdalddkbiejnfmbmlffk). +- [Firefox](https://addons.mozilla.org/en-US/firefox/addon/liveblocks-devtools/). +- [Edge](https://microsoftedge.microsoft.com/addons/detail/liveblocks-devtools/hfecmmnilleegmjaegkjjklnjbgadikg). diff --git a/.agents/skills/liveblocks-best-practices/references/edit-component-text-strings.md b/.agents/skills/liveblocks-best-practices/references/edit-component-text-strings.md new file mode 100644 index 0000000..58c9eb6 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/edit-component-text-strings.md @@ -0,0 +1,40 @@ +--- +title: "Edit component text strings" +--- + +# Edit component text strings + +Overrides can be used to customize components’ strings and localization-related +properties, such as locale and reading direction. For example, you can change +"Reply" to say "Comment", or any other string. They can be set globally for all +components using `LiveblocksUiConfig`: + +```tsx +import { LiveblocksUiConfig } from "@liveblocks/react-ui"; + +export function App() { + return ( + + {/* ... */} + + ); +} +``` + +Overrides can also be set per-component, and these settings will take precedence +over global settings. This is particularly useful in certain contexts, for +example when you’re using a `` component for creating replies to +threads: + +```tsx + +``` + +[A full list of override names are here](https://liveblocks.io/docs/api-reference/liveblocks-react-ui#Override-names). diff --git a/.agents/skills/liveblocks-best-practices/references/exhaustive-deps-with-usemutation.md b/.agents/skills/liveblocks-best-practices/references/exhaustive-deps-with-usemutation.md new file mode 100644 index 0000000..f437d3a --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/exhaustive-deps-with-usemutation.md @@ -0,0 +1,63 @@ +--- +title: "Exhaustive deps with useMutation" +--- + +# Exhaustive deps with useMutation + +`useMutation` from `@liveblocks/react` accepts a dependency array, just like +React's `useCallback`. If your project uses the `react-hooks/exhaustive-deps` +ESLint rule, the linter will **not** automatically check `useMutation` deps +unless you explicitly configure it. + +## The problem + +Without configuration, `useMutation` calls with missing or stale dependencies +won't trigger lint warnings, which can lead to subtle stale-closure bugs: + +```tsx +const name = "Alice"; + +const update = useMutation(({ storage }) => { + storage.get("user").set("name", name); +}, []); +// ~~ ❌ `name` not listed as a dependency +``` + +## The fix + +Add `useMutation` to the `additionalHooks` option of +`react-hooks/exhaustive-deps`. + +### Flat config (`eslint.config.js` / `eslint.config.mjs`) + +```js +import reactHooks from "eslint-plugin-react-hooks"; + +export default [ + reactHooks.configs.flat.recommended, + { + rules: { + "react-hooks/exhaustive-deps": [ + "error", + { additionalHooks: "(useMutation)" }, + ], + }, + }, +]; +``` + +### Legacy config (`.eslintrc` / `.eslintrc.json`) + +```json +{ + "rules": { + "react-hooks/exhaustive-deps": [ + "error", + { "additionalHooks": "(useMutation)" } + ] + } +} +``` + +After this change the linter will warn about missing deps in `useMutation`, just +as it does for `useCallback` and `useMemo`. diff --git a/.agents/skills/liveblocks-best-practices/references/handling-connection-errors.md b/.agents/skills/liveblocks-best-practices/references/handling-connection-errors.md new file mode 100644 index 0000000..a99dbf5 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/handling-connection-errors.md @@ -0,0 +1,120 @@ +--- +title: "Handling connection errors" +--- + +# Handling connection errors + +A connection error may occur when the user cannot authenticate, they don't have +permission to join the room, the room is full, or if the room ID has changed. +[`useErrorListener`](https://liveblocks.io/docs/api-reference/liveblocks-react#useErrorListener) +allows you to catch these errors. It must be used inside of +[`LiveblocksProvider`](https://liveblocks.io/docs/api-reference/liveblocks-react#LiveblocksProvider). + +```tsx +import { useErrorListener } from "@liveblocks/react/suspense"; + +// This component is used within `LiveblocksProvider` +function Comments() { + useErrorListener((error) => { + if (error.context.type === "ROOM_CONNECTION_ERROR") { + const { code } = error.context; + // -1 = Authentication error + // 4001 = You don't have access to this room + // 4005 = Room was full + // 4006 = Room ID has changed + // ... + } + }); + + // ... +} +``` + +Note that this is different to handling a poor or unstable connection, read the +`handling-unstable-connections` reference for that. + +## Other errors + +There are many different errors, and each can be handled separately by checking +the value of `error.context.type`. Below we’ve listed each error and the context +it provides. + +```tsx +import { useErrorListener } from "@liveblocks/react/suspense"; + +useErrorListener((error) => { + switch (error.context.type) { + // Can happen if you use Presence, Storage, or Yjs + case "ROOM_CONNECTION_ERROR": { + const { code } = error.context; + // -1 = Authentication error + // 4001 = You don't have access to this room + // 4005 = Room was full + // 4006 = Room ID has changed + break; + } + + // Can happen if you use Comments or Notifications + case "CREATE_THREAD_ERROR": { + const { roomId, threadId, commentId, body, metadata } = error.context; + break; + } + + case "DELETE_THREAD_ERROR": { + const { roomId, threadId } = error.context; + break; + } + + case "EDIT_THREAD_METADATA_ERROR": { + const { roomId, threadId, metadata } = error.context; + break; + } + + case "MARK_THREAD_AS_RESOLVED_ERROR": + case "MARK_THREAD_AS_UNRESOLVED_ERROR": { + const { roomId, threadId } = error.context; + break; + } + + case "CREATE_COMMENT_ERROR": + case "EDIT_COMMENT_ERROR": { + const { roomId, threadId, commentId, body } = error.context; + break; + } + + case "DELETE_COMMENT_ERROR": { + const { roomId, threadId, commentId } = error.context; + break; + } + + case "ADD_REACTION_ERROR": + case "REMOVE_REACTION_ERROR": { + const { roomId, threadId, commentId, emoji } = error.context; + break; + } + + case "MARK_INBOX_NOTIFICATION_AS_READ_ERROR": { + const { inboxNotificationId, roomId } = error.context; + break; + } + + case "DELETE_INBOX_NOTIFICATION_ERROR": { + const { roomId } = error.context; + break; + } + + case "MARK_ALL_INBOX_NOTIFICATIONS_AS_READ_ERROR": + case "DELETE_ALL_INBOX_NOTIFICATIONS_ERROR": + break; + + case "UPDATE_ROOM_SUBSCRIPTION_SETTINGS_ERROR": { + const { roomId } = error.context; + break; + } + + default: + // Ignore any error from the future + break; + } +}); +``` diff --git a/.agents/skills/liveblocks-best-practices/references/handling-full-rooms.md b/.agents/skills/liveblocks-best-practices/references/handling-full-rooms.md new file mode 100644 index 0000000..f832766 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/handling-full-rooms.md @@ -0,0 +1,34 @@ +--- +title: "Handling full rooms" +--- + +# Handling full rooms + +Only a certain amount of users can join your room, and this limit is set by your +plan, for example 20 or 50. +[`useErrorListener`](https://liveblocks.io/docs/api-reference/liveblocks-react#useErrorListener) +allows you to intercept a user joining a full room and handle it. It must be +used inside of +[`LiveblocksProvider`](https://liveblocks.io/docs/api-reference/liveblocks-react#LiveblocksProvider). + +```tsx +import { useErrorListener, useThreads } from "@liveblocks/react/suspense"; + +// This component is used within `LiveblocksProvider` +function Component() { + useErrorListener((error) => { + if ( + error.context.type === "ROOM_CONNECTION_ERROR" && + error.context.code === 4005 + ) { + // Room is full, handle this by e.g. redirecting or showing an error component + // redirect("/error"); + // ... + } + }); + + // ... +} +``` + +To avoid this happening, read the reference on `avoid-hitting-user-limit-in-rooms`. diff --git a/.agents/skills/liveblocks-best-practices/references/handling-hook-and-component-errors.md b/.agents/skills/liveblocks-best-practices/references/handling-hook-and-component-errors.md new file mode 100644 index 0000000..236e826 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/handling-hook-and-component-errors.md @@ -0,0 +1,144 @@ +--- +title: "Handling hook and component errors" +--- + +# Handling hook and component errors + +Pre-built components such as `Thread` and hooks such as `useCreateThread` can +throw errors. +[`useErrorListener`](https://liveblocks.io/docs/api-reference/liveblocks-react#useErrorListener) +allows you to catch these errors. It must be used inside of +[`LiveblocksProvider`](https://liveblocks.io/docs/api-reference/liveblocks-react#LiveblocksProvider). + +```tsx +import { useErrorListener, useThreads } from "@liveblocks/react/suspense"; + +// This component is used within `LiveblocksProvider` +function Comments() { + const { threads } = useThreads(); + + useErrorListener((error) => { + if (error.context.type === "CREATE_THREAD_ERROR") { + const { roomId, threadId, commentId, body, metadata } = error.context; + // ... + } + }); + + return ( +
+ {threads.map((thread) => ( + + ))} +
+ ); +} +``` + +```tsx +import { useErrorListener, useCreateThread } from "@liveblocks/react/suspense"; + +// This component is used within `LiveblocksProvider` +function Comments() { + const createThread = useCreateThread(); + + useErrorListener((error) => { + if (error.context.type === "CREATE_THREAD_ERROR") { + const { roomId, threadId, commentId, body, metadata } = error.context; + // ... + } + }); + + return ( + + ); +} +``` + +## Other errors + +There are many different errors, and each can be handled separately by checking +the value of `error.context.type`. Below we’ve listed each error and the context +it provides. + +```tsx +import { useErrorListener } from "@liveblocks/react/suspense"; + +useErrorListener((error) => { + switch (error.context.type) { + // Can happen if you use Presence, Storage, or Yjs + case "ROOM_CONNECTION_ERROR": { + const { code } = error.context; + // -1 = Authentication error + // 4001 = You don't have access to this room + // 4005 = Room was full + // 4006 = Room ID has changed + break; + } + + // Can happen if you use Comments or Notifications + case "CREATE_THREAD_ERROR": + const { roomId, threadId, commentId, body, metadata } = error.context; + break; + + case "DELETE_THREAD_ERROR": + const { roomId, threadId } = error.context; + break; + + case "EDIT_THREAD_METADATA_ERROR": + const { roomId, threadId, metadata } = error.context; + break; + + case "MARK_THREAD_AS_RESOLVED_ERROR": + case "MARK_THREAD_AS_UNRESOLVED_ERROR": + const { roomId, threadId } = error.context; + break; + + case "CREATE_COMMENT_ERROR": + case "EDIT_COMMENT_ERROR": + const { roomId, threadId, commentId, body } = error.context; + break; + + case "DELETE_COMMENT_ERROR": + const { roomId, threadId, commentId } = error.context; + break; + + case "ADD_REACTION_ERROR": + case "REMOVE_REACTION_ERROR": + const { roomId, threadId, commentId, emoji } = error.context; + break; + + case "MARK_INBOX_NOTIFICATION_AS_READ_ERROR": + const { inboxNotificationId, roomId } = error.context; + break; + + case "DELETE_INBOX_NOTIFICATION_ERROR": + const { roomId } = error.context; + break; + + case "MARK_ALL_INBOX_NOTIFICATIONS_AS_READ_ERROR": + case "DELETE_ALL_INBOX_NOTIFICATIONS_ERROR": + break; + + case "UPDATE_ROOM_SUBSCRIPTION_SETTINGS_ERROR": + const { roomId } = error.context; + break; + + default: + // Ignore any error from the future + break; + } +}); +``` diff --git a/.agents/skills/liveblocks-best-practices/references/handling-unstable-connections.md b/.agents/skills/liveblocks-best-practices/references/handling-unstable-connections.md new file mode 100644 index 0000000..0884beb --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/handling-unstable-connections.md @@ -0,0 +1,56 @@ +--- +title: "Handling unstable connections" +--- + +# Handling unstable connections + +Very short connections dips are ignored by Liveblocks, and your app will +continue to work correctly. However, when a connection drops for more than 5 +seconds, +[`useLostConnectionListener`](https://liveblocks.io/docs/api-reference/liveblocks-react#useLostConnectionListener) +can be used to render UI, allowing you to tell users about this. It must be used +inside of +[`LiveblocksProvider`](https://liveblocks.io/docs/api-reference/liveblocks-react#LiveblocksProvider). + +```tsx +import { toast } from "my-preferred-toast-library"; + +function App() { + useLostConnectionListener((event) => { + switch (event) { + case "lost": + toast.warn("Still trying to reconnect..."); + break; + + case "restored": + toast.success("Successfully reconnected again!"); + break; + + case "failed": + toast.error("Could not restore the connection"); + break; + } + }); +} +``` + +Edit the amount of time until the callback is called with the +[`lostConnectionTimeout`](https://liveblocks.io/docs/api-reference/liveblocks-react#LiveblocksProviderLostConnectionTimeout) +option on `LiveblocksProvider`. The default is `5000`, 5 seconds. + +```tsx +import { LiveblocksProvider } from "@liveblocks/react/suspense"; + +function App() { + return ( + + {/* children */} + + ); +} +``` diff --git a/.agents/skills/liveblocks-best-practices/references/log-out-of-liveblocks.md b/.agents/skills/liveblocks-best-practices/references/log-out-of-liveblocks.md new file mode 100644 index 0000000..c34e650 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/log-out-of-liveblocks.md @@ -0,0 +1,21 @@ +--- +title: "Log out of Liveblocks" +--- + +# Log out of Liveblocks + +Occasionally it's useful to log out of Liveblocks, for example when you have an +SPA where you wish to reauthenticate a user without refreshing the page. Here's +how to do it: + +```ts +client.logout(); +``` + +In React, get your client like this: + +```tsx +import { useClient } from "@liveblocks/react/suspense"; + +const client = useClient(); +``` diff --git a/.agents/skills/liveblocks-best-practices/references/multiplayer-react-flow.md b/.agents/skills/liveblocks-best-practices/references/multiplayer-react-flow.md new file mode 100644 index 0000000..fda79d9 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/multiplayer-react-flow.md @@ -0,0 +1,75 @@ +--- +title: "Multiplayer React Flow" +--- + +# Multiplayer React Flow + +Use the LiveblocksReact Flow package to create multiplayer diagrams. + +```bash +npm install @liveblocks/react-flow @xyflow/react +``` + +```tsx +"use client"; + +import { ReactFlow } from "@xyflow/react"; +import { useLiveblocksFlow, Cursors } from "@liveblocks/react-flow"; +import "@xyflow/react/dist/style.css"; +import "@liveblocks/react-ui/styles.css"; +import "@liveblocks/react-flow/styles.css"; + +export function Flow() { + const { nodes, edges, onNodesChange, onEdgesChange, onConnect, onDelete } = + useLiveblocksFlow({ + suspense: true, + nodes: { + initial: [ + { + id: "1", + type: "input", + data: { label: "Start" }, + position: { x: 250, y: 0 }, + }, + { + id: "2", + data: { label: "Process" }, + position: { x: 100, y: 110 }, + }, + { + id: "3", + type: "output", + data: { label: "End" }, + position: { x: 250, y: 220 }, + }, + // ... + ], + }, + edges: { + initial: [ + { id: "e1-2", source: "1", target: "2" }, + { id: "e2-3", source: "2", target: "3" }, + // ... + ], + }, + }); + + return ( +
+ + + +
+ ); +} +``` + +Make sure to connect to a room and authenticate users as with all Liveblocks +apps. [Learn more](https://liveblocks.io/docs/get-started/nextjs-react-flow). diff --git a/.agents/skills/liveblocks-best-practices/references/multiple-text-editors.md b/.agents/skills/liveblocks-best-practices/references/multiple-text-editors.md new file mode 100644 index 0000000..6abef99 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/multiple-text-editors.md @@ -0,0 +1,99 @@ +--- +title: "Multiple text editors" +--- + +# Multiple text editors + +Tiptap and BlockNote both support multiple text editors on one page by passing +values to the `field` property. Think of it like an ID for the current editor. + +```tsx +import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; + +function TextEditor() { + const liveblocks = useLiveblocksExtension({ + field: "editor-one", + }); + + // ... +} +``` + +In real use, you could store each editor's ID in Liveblocks storage: + +```tsx +import { useStorage, useMutation } from "@liveblocks/react/suspense"; +import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; +import { useEditor, EditorContent } from "@tiptap/react"; + +function TextEditors() { + const editorIds = useStorage((root) => root.editorIds); + + const newEditor = useMutation(({ storage }) => { + const newId = nanoid(); + storage.get("editorsIds").push(newId); + }, []); + + return ( +
+ {editorIds.map((editorId) => ( + + ))} + +
+ ); +} + +function TextEditor({ field }: { field: string }) { + const liveblocks = useLiveblocksExtension({ + field: "editor-one", + }); + + return ( +
+ +
+ ); +} +``` + +```ts file="liveblocks.config.ts" +import { LiveList } from "@liveblocks/client"; + +declare global { + interface Liveblocks { + Storage: { + editorIds: LiveList; + }; + } +} + +// Necessary if you have no imports/exports +export {}; +``` + +```tsx +import { LiveList } from "@liveblocks/client"; +import { LiveblocksProvider } from "@liveblocks/react/suspense"; + + +``` + +## BlockNote + +BlockNote works in the same way, with this option: + +```tsx +import { useCreateBlockNoteWithLiveblocks } from "@liveblocks/react-blocknote"; + +function TextEditor() { + const editor = useCreateBlockNoteWithLiveblocks( + {}, + { + field: "editor-one", + } + ); + + // ... +} +``` diff --git a/.agents/skills/liveblocks-best-practices/references/offline-support-in-text-editors.md b/.agents/skills/liveblocks-best-practices/references/offline-support-in-text-editors.md new file mode 100644 index 0000000..0ba45c2 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/offline-support-in-text-editors.md @@ -0,0 +1,90 @@ +--- +title: "Offline support in text editors" +--- + +# Offline support in text editors + +Yjs, Tiptap, and BlockNote all have offline support. This means that once a +document has been opened, it’s saved locally on the browser, and can be shown +instantly without a loading screen. As soon as Liveblocks connects, any remote +changes will be synchronized, without any load spinner. Enable this by passing a +`offlineSupport_experimental` value. + +```tsx +import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; + +function TextEditor() { + const liveblocks = useLiveblocksExtension({ + offlineSupport_experimental: true, + }); + + // ... +} +``` + +To make sure that your editor loads instantly, you must structure your app +carefully to avoid any Liveblocks hooks and `ClientSideSuspense` components from +triggering a loading screen. For example, if you’re displaying threads in your +editor with `useThreads`, you must place this inside a separate component and +wrap it in `ClientSideSuspense`. + +```tsx +"use client"; + +import { ClientSideSuspense, useThreads } from "@liveblocks/react/suspense"; +import { + useLiveblocksExtension, + AnchoredThreads, + FloatingComposer, +} from "@liveblocks/react-tiptap"; +import { Editor, EditorContent, useEditor } from "@tiptap/react"; + +export function TiptapEditor() { + const liveblocks = useLiveblocksExtension({ + offlineSupport_experimental: true, + }); + + const editor = useEditor({ + extensions: [ + liveblocks, + // ... + ], + immediatelyRender: false, + }); + + return ( + <> + + + + + + + ); +} + +function Threads({ editor }: { editor: Editor }) { + const { threads } = useThreads(); + + return ; +} +``` + +## BlockNote + +Here's the BlockNote option, it works in the same way: + +```tsx +import { useCreateBlockNoteWithLiveblocks } from "@liveblocks/react-blocknote"; + +function TextEditor() { + const editor = useCreateBlockNoteWithLiveblocks( + {}, + { + offlineSupport_experimental: true, + } + ); + + // ... +} +``` diff --git a/.agents/skills/liveblocks-best-practices/references/override-css-variables.md b/.agents/skills/liveblocks-best-practices/references/override-css-variables.md new file mode 100644 index 0000000..af2ffb4 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/override-css-variables.md @@ -0,0 +1,41 @@ +--- +title: "Override CSS variables" +--- + +# Override CSS variables + +Style Liveblocks by using CSS variables. They can be applied to every Liveblocks +element: + +```css +/* Styles all default Comments components */ +.lb-root { + --lb-accent: purple; + --lb-spacing: 1em; + --lb-radius: 0; +} +``` + +You can also choose to only apply them to certain elements: + +```css +/* Styles only composers */ +.lb-composer { + --lb-line-height: 1.3; +} +``` + +Some elements have data attributes to provide contextual information. Also +remember that regular CSS properties work too. + +```css +.lb-button[data-variant="primary"] { + --lb-accent: blue; +} + +.lb-avatar[data-loading] { + opacity: 0.8; +} +``` + +[A full list of variables is here](https://liveblocks.io/docs/api-reference/liveblocks-react-ui#CSS-variables). diff --git a/.agents/skills/liveblocks-best-practices/references/performant-others-and-presence.md b/.agents/skills/liveblocks-best-practices/references/performant-others-and-presence.md new file mode 100644 index 0000000..fb80473 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/performant-others-and-presence.md @@ -0,0 +1,125 @@ +--- +title: "Performant others and presence" +--- + +# Performant others and presence + +Liveblocks presence and others can update as much as 60 times per second. This +means it's best to build your app in ways that don't cause unnecessary renders. + +## ❌ useOthers updates a lot + +[`useOthers`](https://liveblocks.io/docs/api-reference/liveblocks-react#useOthers) +returns an array of other users in the room. This array is updated every time +any users presence changes, which can be many times per second. + +```tsx +import { useOthers } from "@liveblocks/react/suspense"; + +// ❌ Updates on every presence change +function Cursors() { + const others = useOthers(); + + return ( +
+ {others.map((other) => ( + + ))} +
+ ); +} +``` + +## ✅ useOtherConnectionIds updates only when users join or leave + +Using +[`useOtherConnectionIds`](https://liveblocks.io/docs/api-reference/liveblocks-react#useOtherConnectionIds) +returns an array of connection IDs for other users in the room. This array is +only updated when a user joins or leaves the room, and NOT when a user's +presence changes. + +Use this in combination with +[`useOther`](https://liveblocks.io/docs/api-reference/liveblocks-react#useOther) +to update components only when this user's presence changes. + +```tsx +import { useOthers } from "@liveblocks/react/suspense"; + +// ✅ Updates only when users join or leave +function Cursors() { + const others = useOthers(); + + return ( +
+ {others.map((other) => ( + + ))} +
+ ); +} + +// ✅ Updates only when this user's presence changes +function Cursor({ connectionId }: { connectionId: number }) { + const other = useOther(connectionId); + + return ( + + ); +} +``` + +## ✅ useOthersMapped updates only when a subset of their presence changes + +Using +[`useOthersMapped`](https://liveblocks.io/docs/api-reference/liveblocks-react#useOthersMapped) +returns an array of a certain part of user's presence, and only updates when +this part of the presence changes. For example, if your app haas the concept of +a user typing, and if you'd just like to check if each user is typing or not, +return an array of their `isTyping` properties. + +```tsx +import { useOthersMapped } from "@liveblocks/react/suspense"; + +// ✅ Updates only when a user's `isTyping` property changes +function Typing() { + const others = useOthersMapped((other) => other.presence.isTyping); + + // [true, false, true] + console.log(others); + + // ... +} +``` + +## useOthers with a selector + +With a selector and the `shallow` option, you can run operations on the list, +for example `.filter`, and return only users that are typing. `shallow` is a +shallow comparison, that checks if the array values have changed, instead of +checking if the array reference has changed. + +```tsx +import { shallow, useOthers } from "@liveblocks/react/suspense"; + +// Updates when any users presence value changes, but not on every render +function Typing() { + const typingUsers = useOthers( + (others) => others.filter((other) => other.presence.isTyping), + shallow // 👈 + ); + + // ... +} +``` + +This still updates when any user's presence value changes, but its more +efficient that not using `shallow`, which would update it on every render. diff --git a/.agents/skills/liveblocks-best-practices/references/performant-storage-with-selectors.md b/.agents/skills/liveblocks-best-practices/references/performant-storage-with-selectors.md new file mode 100644 index 0000000..254b96f --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/performant-storage-with-selectors.md @@ -0,0 +1,110 @@ +--- +title: "Performant storage with selectors" +--- + +# Performant storage with selectors + +Using selectors with +[`useStorage`](https://liveblocks.io/docs/api-reference/liveblocks-react#useStorage) +can help you avoid unnecessary renders. Selectors are functions that return a +subset of the storage tree, for example `(root) => root.animals` will only +render when `animals` changes. + +```tsx +import { useStorage } from "@liveblocks/react/suspense"; + +function Storage() { + const storage = useStorage((root) => root.animals); + + // ... +} +``` + +## Examples + +Here's some more comples examples, given the following Storage types: + +```ts file="liveblocks.config.ts" +import { LiveList, LiveMap, LiveObject } from "@liveblocks/client"; + +type Tags = LiveList; + +type Shape = LiveObject<{ + id: string; + color: string; + tags: Tags; +}>; + +declare global { + interface Liveblocks { + Storage: { + shapes: LiveList; + people: LiveMap; + }; + } +} +``` + +`useStorage` updates whenever the selector value changes, so if you create a new +array, map, or object (e.g. with `.filter`), make sure to pass a `shallow` +comparison. Here's how they render: + +```tsx +import { shallow, useStorage } from "@liveblocks/react/suspense"; + +// ❌ Renders when people or shapes change +const storage = useStorage((root) => root); + +// Renders when any shape changes +const storage = useStorage((root) => root.shapes); + +// Renders when only the first shape changes +const firstShape = useStorage((root) => root.shapes[0]); + +// Renders when only the first shape's color changes +const firstShapeColor = useStorage((root) => root.shapes[0].color); + +// Renders when only the first shape's first tag changes +const firstShapeTags = useStorage((root) => root.shapes[0].tags[0]); + +// Renders when only the a shape's first tag length changes +const thisShapesTagLength = useStorage( + (root) => root.shapes[SHAPE_INDEX].tags.length +); + +// Renders when a shape becomes red, or is no longer red +const redShapes = useStorage( + (root) => root.shapes.filter((shape) => shape.color === "red"), + shallow // 👈 +); +``` + +## ✅ Be efficient + +Try to be efficient as possible at every stage. For example, use `useStorage` in +two places, like this. First get the key of every shape, then get the shape from +its key. + +```tsx +import { shallow, useStorage } from "@liveblocks/react/suspense"; + +function Canvas() { + // ✅ Only updates when a shape is added or removed to the LiveList + const shapeIds = useStorage( + (root) => root.shapes.map((shape) => shape.id), + shallow + ); + + return shapeIds.map((id) => ); +} + +function Shape({ id }: { id: string }) { + // ✅ Updates only when this shape LiveObject changes + const shape = useStorage( + (root) => root.shapes.find((shape) => shape.id === id), + shallow + ); + + return
; +} +``` diff --git a/.agents/skills/liveblocks-best-practices/references/prevent-unsaved-changes-being-lost.md b/.agents/skills/liveblocks-best-practices/references/prevent-unsaved-changes-being-lost.md new file mode 100644 index 0000000..9bdeaca --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/prevent-unsaved-changes-being-lost.md @@ -0,0 +1,28 @@ +--- +title: "Prevent unsaved changes being lost" +--- + +# Prevent unsaved changes being lost + +Liveblocks usually synchronizes milliseconds after a local change, but if a user +immediately closes their tab, or if they have a slow connection, it may take +longer for changes to synchronize. Enabling preventUnsavedChanges will stop tabs +with unsaved changes closing, by opening a dialog that warns users. In usual +circumstances, it will very rarely trigger. + +```tsx +function Page() { + return ( + + ... + + ); +} +``` + +[More info](https://liveblocks.io/docs/api-reference/liveblocks-react#prevent-users-losing-unsaved-changes). diff --git a/.agents/skills/liveblocks-best-practices/references/primitive-component-parts.md b/.agents/skills/liveblocks-best-practices/references/primitive-component-parts.md new file mode 100644 index 0000000..3a564e6 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/primitive-component-parts.md @@ -0,0 +1,80 @@ +--- +title: "Primitive component parts" +--- + +# Primivite component parts + +Primitives are headless and unstyled components, and can be used to construct +components that fit your own design system. Each primitive is made up of one or +more components, for example `Comment` only needs `Comment.Body`. This component +takes a comment’s body, and turns it into readable text, with links and +mentions. + +```tsx +import { CommentData } from "@liveblocks/client"; +import { Comment } from "@liveblocks/react-ui/primitives"; + +// Render a custom comment body +function MyComment({ comment }: { comment: CommentData }) { + return ( + {}} + /> + ); +} +``` + +Many primitives allow you to customize their parts by passing a `components` +property. In this example, we’re modifying links in the comment, so that they’re +purple and bold. + +```tsx +import { CommentData } from "@liveblocks/client"; +import { Comment, CommentBodyLinkProps } from "@liveblocks/react-ui/primitives"; + +// Render a custom comment body +function MyComment({ comment }: { comment: CommentData }) { + return ( +
+ +
+ ); +} + +// Render a purple link in the comment, e.g. "https://liveblocks.io" +function Link({ href, children }: CommentBodyLinkProps) { + return ( + + {children} + + ); +} +``` + +Merge with your design system components. `asChild` is helpful. + +```tsx +function DesignSystemLink({ url, children }) { + return ( + + {children} + + ); +} + +function Link({ href, children }: CommentBodyLinkProps) { + return ( + + {children} + + ); +} +``` diff --git a/.agents/skills/liveblocks-best-practices/references/remove-liveblocks-branding.md b/.agents/skills/liveblocks-best-practices/references/remove-liveblocks-branding.md new file mode 100644 index 0000000..552a695 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/remove-liveblocks-branding.md @@ -0,0 +1,30 @@ +--- +title: "Remove Liveblocks branding" +--- + +# Remove Liveblocks branding + +By default, Liveblocks displays a "Powered by Liveblocks" badge in your +application. If you wish to remove remove the badge entirely, you can do so by +following these steps: + +In the Liveblocks dashboard, navigate to your team’s settings. Under General, +toggle on the remove "Powered by Liveblocks" branding option. + +--- + +Removing the "Powered by Liveblocks" badge on your projects requires a paid +plan. See the [pricing page](https://liveblocks.io/pricing) for more +information. + +## Adjust its position + +You can adjust the position of the badge by setting the badgeLocation property +on LiveblocksProvider. + +```tsx +// "top-right", "bottom-right", "bottom-left", "top-left" + + + +``` diff --git a/.agents/skills/liveblocks-best-practices/references/rendering-error-components.md b/.agents/skills/liveblocks-best-practices/references/rendering-error-components.md new file mode 100644 index 0000000..4710a0a --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/rendering-error-components.md @@ -0,0 +1,139 @@ +--- +title: "Rendering error components" +--- + +# Rendering error components + +It's recommended to structure your app using the suspense version of Liveblocks +hooks, alongside `ErrorBoundary` and its `fallback` property used as a an error +component. Make sure it's installed: + +``` +npm install react-error-boundary +``` + +Instead of wrapping your entire Liveblocks application inside single +`ClientSideSuspense` and `ErrorBoundary` components, you can use multiple of +these components in different parts of your application, and each will work as a +loading fallbacks and error fallbacks for any components further down your tree. + +Simple hooks don't need error boundaries, but hooks returning a `{ ... }` format +do. + +```tsx +import { + ClientSideSuspense, + useThreads, + useOthers, +} from "@liveblocks/react/suspense"; // ← /suspense export +import { ErrorBoundary } from "react-error-boundary"; + +function Page() { + return ( + + +
My title
+ +
+ // +++ + Canvas error
}> + Loading…}> + + + + // +++ + + + + +
+ ); +} + +function Comments() { + const { threads } = useThreads(); // ← complex hooks use { ... } and can error + + // ... +} + +function Avatars() { + const others = useOthers(); // ← simple hook, never errors + + // ... +} +``` + +Note that suspense hooks are exported from `"@liveblocks/react/suspense"`. This +is a great way to build a static skeleton around your dynamic collaborative +application. + +## When not using suspense + +When not using suspense, you can check for `null` or `isLoading` to create +loading components. Most hooks use `null` such as `useStorage`, but complex +paginated hooks like `useThreads` and `useInboxNotifications` return an +`isLoading` state. + +```tsx +import { useThreads, useOthers } from "@liveblocks/react"; // ← NOT /suspense export +import { ErrorBoundary } from "react-error-boundary"; + +function Page() { + return ( + + +
My title
+ +
+ // +++ + + // +++ +
+ + +
+
+ ); +} + +function Comments() { + const { threads, error, isLoading } = useThreads(); // ← complex hooks use { ... } and can error + + if (isLoading) { + return
Loading...
; + } + + if (error) { + return
Comments error
; + } + + // ... +} + +function Avatars() { + const others = useOthers(); // ← simple hook, never errors + + if (!others) { + return
Loading...
; + } + + // ... +} +``` + +Note that regular hooks are exported from `"@liveblocks/react"`. + +## Summary + +It's recommend to use the suspense versions of hooks and `ErrorBoundary` to +create loading states. diff --git a/.agents/skills/liveblocks-best-practices/references/rendering-loading-components.md b/.agents/skills/liveblocks-best-practices/references/rendering-loading-components.md new file mode 100644 index 0000000..1e7424e --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/rendering-loading-components.md @@ -0,0 +1,135 @@ +--- +title: "Rendering loading components" +--- + +# Rendering loading components + +It's recommended to structure your app using the suspense version of Liveblocks +hooks, alongside `ClientSideSuspense` and its `fallback` property used as a +loading spinner. + +Instead of wrapping your entire Liveblocks application inside single +`ClientSideSuspense` and `ErrorBoundary` components, you can use multiple of +these components in different parts of your application, and each will work as a +loading fallbacks and error fallbacks for any components further down your tree. + +```tsx +import { + ClientSideSuspense, + useThreads, + useOthers, +} from "@liveblocks/react/suspense"; // ← /suspense export +import { ErrorBoundary } from "react-error-boundary"; + +function Page() { + return ( + + +
My title
+ +
+ // +++ + Canvas error}> + Loading…}> + + + + // +++ +
+ + +
+
+ ); +} + +function Comments() { + const { threads } = useThreads(); // ← complex hooks use { ... } and can error + + // ... +} + +function Avatars() { + const others = useOthers(); // ← simple hook, never errors + + // ... +} +``` + +Note that suspense hooks are exported from `"@liveblocks/react/suspense"`. This +is a great way to build a static skeleton around your dynamic collaborative +application. + +## When not using suspense + +When not using suspense, you can check for `null` or `isLoading` to create +loading components. Most hooks use `null` such as `useStorage`, but complex +paginated hooks like `useThreads` and `useInboxNotifications` return an +`isLoading` state. + +Simple hooks don't need error boundaries, but hooks returning a `{ ... }` format +do. + +```tsx +import { useThreads, useOthers } from "@liveblocks/react"; // ← NOT /suspense export +import { ErrorBoundary } from "react-error-boundary"; + +function Page() { + return ( + + +
My title
+ +
+ // +++ + + // +++ +
+ + +
+
+ ); +} + +function Comments() { + const { threads, error, isLoading } = useThreads(); // ← complex hooks use { ... } and can error + + if (isLoading) { + return
Loading...
; + } + + if (error) { + return
Comments error
; + } + + // ... +} + +function Avatars() { + const others = useOthers(); // ← simple hook, never errors + + if (!others) { + return
Loading...
; + } + + // ... +} +``` + +Note that regular hooks are exported from `"@liveblocks/react"`. + +## Summary + +It's recommend to use the suspense versions of hooks and `ClientSideSuspense` to +create loading states. diff --git a/.agents/skills/liveblocks-best-practices/references/send-comment-notification-emails.md b/.agents/skills/liveblocks-best-practices/references/send-comment-notification-emails.md new file mode 100644 index 0000000..a39f0e7 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/send-comment-notification-emails.md @@ -0,0 +1,68 @@ +--- +title: "Send comment notification emails" +--- + +# Send comment notification emails + +`@liveblocks/emails` provides a set of functions that simplifies sending styled +emails with Notifications and webhooks from Node.js. This is helpful when you'd +like to notify users about unread comments in your application. + +Example: + +```ts +import { isThreadNotificationEvent, WebhookHandler } from "@liveblocks/node"; +import { Liveblocks } from "@liveblocks/node"; +import { prepareThreadNotificationEmailAsReact } from "@liveblocks/emails"; + +const liveblocks = new Liveblocks({ + secret: "sk_prod_xxxxxxxxxxxxxxxxxxxxxxxx", +}); + +const webhookHandler = new WebhookHandler( + process.env.LIVEBLOCKS_WEBHOOK_SECRET_KEY as string +); + +export async function POST(request: Request) { + const body = await request.json(); + const headers = request.headers; + + // Verify if this is a real webhook request + let event; + try { + event = webhookHandler.verifyRequest({ + headers: headers, + rawBody: JSON.stringify(body), + }); + } catch (err) { + console.error(err); + return new Response("Could not verify webhook call", { status: 400 }); + } + + // Using `@liveblocks/emails` to create an email + if (isThreadNotificationEvent(event)) { + const emailData = await prepareThreadNotificationEmailAsReact( + liveblocks, + event + ); + + if (emailData.type === "unreadMention") { + const email = ( +
+
+ @{emailData.comment.author.id} at {emailData.comment.createdAt} +
+
{emailData.comment.body}
+
+ ); + + // Send unread mention email + // ... + } + } + + return new Response(null, { status: 200 }); +} +``` + +[Learn more](https://liveblocks.io/docs/api-reference/liveblocks-emails.md). diff --git a/.agents/skills/liveblocks-best-practices/references/smoother-realtime-updates.md b/.agents/skills/liveblocks-best-practices/references/smoother-realtime-updates.md new file mode 100644 index 0000000..03998fc --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/smoother-realtime-updates.md @@ -0,0 +1,31 @@ +--- +title: "Smoother realtime updates" +--- + +# Smoother realtime updates + +By default, Liveblocks presence and storage updates 10 times per second. Make it +smoother by updating the +[`throttle`](https://liveblocks.io/docs/api-reference/liveblocks-react#LiveblocksProviderThrottle) +value. The default is `100`, once every 100ms, 10FPS. The minimum value is `16`, +once every 16ms, 60FPS. + +```tsx +import { LiveblocksProvider } from "@liveblocks/react/suspense"; + +function App() { + return ( + + {/* children */} + + ); +} +``` + +Note that rooms with lots of high frequency updates by multiple users may become +laggy with a low throttle value. diff --git a/.agents/skills/liveblocks-best-practices/references/suspense-vs-regular-hooks.md b/.agents/skills/liveblocks-best-practices/references/suspense-vs-regular-hooks.md new file mode 100644 index 0000000..82b266e --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/suspense-vs-regular-hooks.md @@ -0,0 +1,187 @@ +--- +title: "Suspense vs Regular hooks" +--- + +# Suspense vs Regular hooks + +All Liveblocks React components and hooks can be exported from two different +locations, `@liveblocks/react/suspense` and `@liveblocks/react`. This is because +Liveblocks provides two types of hooks; those that support +[React Suspense](https://react.dev/reference/react/Suspense), and those that +don’t. + +```tsx +// Import the Suspense hook +import { useThreads } from "@liveblocks/react/suspense"; + +// Import the regular hook +import { useThreads } from "@liveblocks/react"; +``` + +### Suspense hooks (often easier) + +Suspense hooks can be wrapped in `ClientSideSuspense`, which acts as a +loading spinner for any components below it. When using this, all components +below will only render once their hook contents have been loaded. + +```tsx +import { ClientSideSuspense, useStorage } from "@liveblocks/react/suspense"; + +function App() { + Loading…}> + + ; +} + +function Component() { + // `animals` is always defined + const animals = useStorage((root) => root.animals); + + // ... +} +``` + +Advanced hooks using the `{ ..., error, isLoading }` syntax, such as +[`useThreads`](https://liveblocks.io/docs/api-reference/liveblocks-react#useThreads), +can also use [`ErrorBoundary`](https://github.com/bvaughn/react-error-boundary) +to render an error if the hook runs into a problem. + +```tsx +import { ClientSideSuspense, useThreads } from "@liveblocks/react/suspense"; +import { ErrorBoundary } from "react-error-boundary"; + +function App() { + return ( + Error}> + Loading…}> + + + + ); +} + +function Component() { + // `threads` is always defined + const { threads } = useThreads(); + + // ... +} +``` + +An advantage of Suspense hooks is that you can have multiple different hooks in +your tree, and you only need a single `ClientSideSuspense` component to render a +loading spinner for all of them. + +### Regular hooks (often harder) + +While it's easier to use Suspense hooks, regular hooks are available too. +Regular hooks often return `null` whilst a component is loading, and you must +check for this to render a loading spinner. + +```tsx +import { useStorage } from "@liveblocks/react"; + +function Component() { + // `animals` is `null` when loading + const animals = useStorage((root) => root.animals); + + if (!animals) { + return
Loading…
; + } + + // ... +} +``` + +Advanced hooks using the `{ ..., error, isLoading }` syntax, such as +[`useThreads`](https://liveblocks.io/docs/api-reference/liveblocks-react#useThreads), +require you to make sure there isn’t a problem before using the data. + +```tsx +import { useThreads } from "@liveblocks/react"; + +function Component() { + // Check for `error` and `isLoading` before `threads` is defined + const { threads, error, isLoading } = useThreads(); + + if (error) { + return
Error
; + } + + if (isLoading) { + return
Loading…
; + } + + // ... +} +``` + +### ClientSideSuspense + +Liveblocks provides a component named `ClientSideSuspense` which works as a +replacement for `Suspense`. This is helpful as our Suspense hooks will throw an +error when they’re run on the server, and this component avoids this issue by +always rendering the `fallback` on the server. + +```tsx +import { ClientSideSuspense } from "@liveblocks/react/suspense"; + +function Page() { + return ( + + + +++ + Loading…}> + + + +++ + + + ); +} +``` + +### Loading spinners and error components + +Instead of wrapping your entire Liveblocks application inside single +`ClientSideSuspense` and `ErrorBoundary` components, you can use multiple of +these components in different parts of your application, and each will work as a +loading fallbacks and error fallbacks for any components further down your tree. + +```tsx +import { ClientSideSuspense } from "@liveblocks/react/suspense"; +import { ErrorBoundary } from "react-error-boundary"; + +function Page() { + return ( + + +
My title
+ +
+ // +++ + Canvas error}> + Loading…}> + + + + // +++ +
+ + +
+
+ ); +} +``` + +This is a great way to build a static skeleton around your dynamic collaborative +application. diff --git a/.agents/skills/liveblocks-best-practices/references/tiptap-best-practices.md b/.agents/skills/liveblocks-best-practices/references/tiptap-best-practices.md new file mode 100644 index 0000000..aeffa7e --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/tiptap-best-practices.md @@ -0,0 +1,429 @@ +--- +title: "Tiptap best practices" +--- + +# Tiptap best practices + +## Always include StarterKit or Doc & Paragraph extensions + +The Tiptap +[StarterKit](https://tiptap.dev/docs/editor/extensions/functionality/starterkit) +extension is **required** for Tiptap to function properly. It provides essential +node types that every Tiptap editor needs, specifically the `Doc` and +`Paragraph` nodes. Alternatively you can include only the +[Doc](https://tiptap.dev/docs/editor/extensions/nodes/document) and +[Paragraph](https://tiptap.dev/docs/editor/extensions/nodes/paragraph) +extensions. + +### Why StarterKit is required + +Tiptap documents must have a root `Doc` node and at least one `Paragraph` node +to maintain a valid document structure. Without these core nodes, the editor +will not work correctly and synchronization will fail. + +```tsx +import { useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; + +const editor = useEditor({ + extensions: [ + // +++ + StarterKit.configure({ + // Required, Liveblocks extension handles its own history + undoRedo: false, + }), + // +++ + // Add other extensions here + ], +}); +``` + + + +`undoRedo` used to be called `history` in Tiptap v2. + + + +### What StarterKit includes + +In addition to the required `Doc` and `Paragraph` nodes, StarterKit also +includes commonly used extensions like: + +- Text formatting (Bold, Italic, Strike, Code) +- Block types (Heading, Blockquote, CodeBlock, BulletList, OrderedList, + ListItem) +- Horizontal rule and hard break +- History (undo/redo) (This must be disabled to work with Liveblocks) + +If you need more control over which extensions are included, you can configure +StarterKit to disable specific extensions: + +```tsx +const editor = useEditor({ + extensions: [ + StarterKit.configure({ + // Disable extensions you don't need + // +++ + heading: false, + blockquote: false, + // +++ + // Required, Liveblocks extension handles its own history + undoRedo: false, + }), + ], +}); +``` + +However, you should **never disable** the `doc` or `paragraph` options, as these +are required for the editor to function. + +## Disable server-side rendering with immediatelyRender: false + +When using Tiptap with server-side rendering (SSR) frameworks like Next.js, you +should _always_ set `immediatelyRender: false` in your `useEditor` hook. Tiptap +should never be rendered on the server. + +### Why disable server-side rendering? + +Tiptap is a client-side editor that relies on browser APIs and the DOM. When +rendered on the server, it can cause: + +- Hydration mismatches between server and client +- Errors related to missing browser APIs + +### How to disable server-side rendering + +Set `immediatelyRender: false` in your `useEditor` configuration: + +```tsx +// ✅ CORRECT: Disable server-side rendering for Next.js and other SSR frameworks +import { useEditor } from "@tiptap/react"; +import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; +import StarterKit from "@tiptap/starter-kit"; + +function Editor() { + const liveblocks = useLiveblocksExtension(); + + const editor = useEditor({ + // Required for SSR frameworks like Next.js + // +++ + immediatelyRender: false, + // +++ + extensions: [ + StarterKit.configure({ + // Required, Liveblocks extension handles its own history + undoRedo: false, + }), + liveblocks, + ], + }); + + return ; +} +``` + +This ensures that Tiptap is only rendered on the client side, avoiding any +server-side rendering issues. + + + +If you're using Next.js (App Router or Pages Router) or any other SSR framework, +**always set `immediatelyRender: false`**. Failing to do so will cause hydration +errors and other unexpected behavior. + + + +## Enable content validation + +Tiptap has a schema that defines the structure of your document. By default, +Tiptap does **not validate content** against this schema. When invalid content +is present, it will **silently break synchronization** without any error +messages. + +This can happen when: + +- Extensions are added or removed from the editor +- The schema changes between different versions of your application +- Users collaborate on documents with different editor configurations + +### How to enable content validation + +Always enable content validation by setting `enableContentCheck: true` and +implementing an `onContentError` handler: + +```tsx +import { useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; + +const editor = useEditor({ + extensions: [ + StarterKit.configure({ + // Required, Liveblocks extension handles its own history + undoRedo: false, + }), + ], + // +++ + enableContentCheck: true, + onContentError: ({ editor, error, disableCollaboration }) => { + // Disable collaboration to prevent data corruption + disableCollaboration(); + + // Make the editor read-only to prevent further changes + editor.setEditable(false, false); + + // Log the error for debugging + console.error("Content validation error:", error); + + // Notify the user that there's an issue + alert( + "There was an error loading this document. The content may be incompatible with the current editor version. The document has been made read-only to prevent data loss." + ); + }, + // +++ +}); +``` + +This ensures that: + +- Invalid content is detected early +- Collaboration is disabled to prevent data corruption +- The editor is made read-only to prevent further changes +- The issue is logged for debugging +- Users are notified of the problem + +## Use initialContent instead of content + +When setting default content for your Tiptap editor, always use +[`initialContent`](/docs/api-reference/liveblocks-react-tiptap#Setting-initial-content) +on +[`useLiveblocksExtension`](/docs/api-reference/liveblocks-react-tiptap#useLiveblocksExtension) +instead of `content` on `useEditor`. Using `content` will cause the content to +be appended to the document every time the page loads or the component +re-renders. + +### The problem with content + +The `content` option in Tiptap sets the editor content every time the editor is +initialized. When using Liveblocks, this means the content will be **added** to +the existing document rather than replacing it, causing duplication: + +```tsx +// ❌ AVOID: This will duplicate content on every page load +const editor = useEditor({ + extensions: [ + StarterKit.configure({ + // Required, Liveblocks extension handles its own history + undoRedo: false, + }), + liveblocks, + ], + // ❌ This text will be added to the document every time the editor is loaded + // +++ + content: "

Default text

", + // +++ +}); +``` + +### Use initialContent instead + +The `initialContent` option sets a flag internally and only sets the content the +**very first time** the document is empty. This prevents duplication: + +```tsx +// ✅ CORRECT: This only sets content once +import { useEditor } from "@tiptap/react"; +import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; +import StarterKit from "@tiptap/starter-kit"; + +function Editor() { + const liveblocks = useLiveblocksExtension({ + // ✅ This text is only set the first time the room is used + // +++ + initialContent: "

Default text

", + // +++ + }); + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + // Required, Liveblocks extension handles its own history + undoRedo: false, + }), + liveblocks, + ], + }); + + return ; +} +``` + + + +The +[`initialContent`](/docs/api-reference/liveblocks-react-tiptap#Setting-initial-content) +option does not currently work when using the +[`field`](/docs/api-reference/liveblocks-react-tiptap#Multiple-editors) option +for multiple editors. If you need to set initial content for multiple editors, +you'll need to implement your own logic to check if the document is empty before +setting content. + + + +## Support multiple editors with the field option + +If you want to display multiple Tiptap editors on the same page, use the +[`field`](/docs/api-reference/liveblocks-react-tiptap#Multiple-editors) option +with a unique identifier for each editor. This ensures that each editor +synchronizes to its own section of the Yjs document. + +```tsx +import { useEditor } from "@tiptap/react"; +import { useLiveblocksExtension } from "@liveblocks/react-tiptap"; +import StarterKit from "@tiptap/starter-kit"; + +function EditorOne() { + const liveblocks = useLiveblocksExtension({ + // Unique identifier for this editor + // +++ + field: "editor-1", + // +++ + }); + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + // Required, Liveblocks extension handles its own history + undoRedo: false, + }), + liveblocks, + ], + }); + + return ; +} + +function EditorTwo() { + const liveblocks = useLiveblocksExtension({ + // Different unique identifier + // +++ + field: "editor-2", + // +++ + }); + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + // Required, Liveblocks extension handles its own history + undoRedo: false, + }), + liveblocks, + ], + }); + + return ; +} +``` + +Without the `field` option, both editors would synchronize to the same location +in the document, causing conflicts and data loss. + +## Never use extensions with binary data + +Never use Tiptap extensions that store binary data (such as base64-encoded +images) directly in the document. Binary data will be synchronized across all +clients and can fill up your Liveblocks room extremely quickly, leading to: + +- Increased bandwidth usage +- Slower synchronization +- Higher storage costs +- Potential rate limiting + +### Common pitfall: Image extension + +The official Tiptap +[Image extension](https://tiptap.dev/docs/editor/extensions/nodes/image) has an +`allowBase64` option. This option is **defaulted to `false`**, and it should +**never be set to `true`**. + +```tsx +// ❌ NEVER DO THIS +import Image from "@tiptap/extension-image"; + +const editor = useEditor({ + extensions: [ + StarterKit.configure({ + // Required, Liveblocks extension handles its own history + undoRedo: false, + }), + Image.configure({ + // This will quickly fill up your room and cause problems + // +++ + allowBase64: true, + // +++ + }), + ], +}); +``` + +### Recommended approach + +Instead of storing images as base64 in the document: + +1. Upload images to a file storage service (e.g., AWS S3, Cloudflare R2, Vercel + Blob) +2. Store only the URL in the document +3. Reference the URL in your image nodes + +```tsx +// ✅ CORRECT: Store only URLs +import Image from "@tiptap/extension-image"; + +const editor = useEditor({ + extensions: [ + StarterKit.configure({ + // Required, Liveblocks extension handles its own history + undoRedo: false, + }), + Image.configure({ + // The default option. Never set to `true` as it will cause issues with Liveblocks + // +++ + allowBase64: false, + // +++ + }), + ], +}); + +// When adding an image +// +++ +editor.commands.setImage({ + // URL only, no base64 + src: "https://your-storage.com/images/photo.jpg", +}); +// +++ +``` + +## Prevent users from losing unsaved changes + +To prevent users from accidentally losing their work when closing the browser +tab or navigating away, enable the +[`preventUnsavedChanges`](/docs/api-reference/liveblocks-react#prevent-users-losing-unsaved-changes) +option: + +```tsx +import { RoomProvider } from "@liveblocks/react/suspense"; + + + {/* Your components */} +; +``` + +This will display a browser confirmation dialog when users try to leave the page +with unsaved changes, helping prevent accidental data loss. +[Learn more](/docs/api-reference/liveblocks-react#prevent-users-losing-unsaved-changes). diff --git a/.agents/skills/liveblocks-best-practices/references/trigger-custom-notifications.md b/.agents/skills/liveblocks-best-practices/references/trigger-custom-notifications.md new file mode 100644 index 0000000..7111fe2 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/trigger-custom-notifications.md @@ -0,0 +1,210 @@ +--- +title: "Trigger custom notifications" +--- + +# Trigger custom notifications + +Notifications are triggered in Liveblocks when a user is tagged or has an unread +comment. However, you can also render completely custom notifications with +[`triggerInboxNotification`](https://liveblocks.io/docs/api-reference/liveblocks-node#post-inbox-notifications-trigger), +useful for any purpose. + +```tsx +import { Liveblocks } from "@liveblocks/node"; + +const liveblocks = new Liveblocks({ + secret: process.env.LIVEBLOCKS_SECRET_KEY!, +}); + +await liveblocks.triggerInboxNotification({ + // The ID of the user that will receive the inbox notification + userId: "steven@example.com", + + // The custom notification kind, must start with a $ + kind: "$fileUploaded", + + // Custom ID for this specific notification + subjectId: "my-file", + + // Custom data related to the activity that you need to render the inbox notification + activityData: { + // Data can be a string, number, or boolean + file: "https://example.com/my-file.zip", + size: 256, + success: true, + }, + + // Optional, define the room ID the notification was sent from + roomId: "my-room-id", + + // Optional, trigger it for a specific organization + organizationId: "acme-corp", +}); +``` + +To type custom notifications, edit the ActivitiesData type in your config file. + +```ts file="liveblocks.config.ts" +declare global { + interface Liveblocks { + // Custom activities data for custom notification kinds + ActivitiesData: { + // Example, a custom $alert kind + $alert: { + title: string; + message: string; + }; + }; + + // Other kinds + // ... + } +} +``` + +## Render in your app + +Render them in your inbox like this: + +```tsx +import { useInboxNotifications } from "@liveblocks/react/suspense"; +import { InboxNotification } from "@liveblocks/react-ui"; + +const { inboxNotifications } = useInboxNotifications(); + +inboxNotifications.map((inboxNotification) => ( + { + const { title, message } = props.inboxNotification.activities[0].data; + + return ( + ❗} + > + {message} + + ); + }, + }} + /> +)); +``` + +Alternatively, structure like this: + +```tsx +import { + InboxNotification, + InboxNotificationCustomKindProps, +} from "@liveblocks/react-ui"; + +function AlertNotification(props: InboxNotificationCustomKindProps<"$alert">) { + // `title` and `message` are correctly typed, as defined in your config + const { title, message } = props.inboxNotification.activities[0].data; + + return ( + ❗} + > + {message} + + ); +} + +function Notification({ inboxNotification }) { + return ( + + ); +} +``` + +## Batching custom notifications + +You can configure a custom notification kind to have batching enabled. When it’s +enabled, triggering an inbox notification activity for a specific `subjectId`, +will update the existing inbox notification instead of creating a new one. + +To use this, you must first +[enable batching in the dashboard](https://liveblocks.io/docs/ready-made-features/notifications/concepts#Notification-batching). +Next, trigger a notification with the same `subjectId` as an existing +notification, and the result will be added to the `activityData` array. + +```ts +const options = { + userId: "steven@example.com", + kind: "$fileUploaded", + subjectId: "my-file", +}; + +await liveblocks.triggerInboxNotification({ + ...options, + + activityData: { + status: "processing", + }, +}); + +await liveblocks.triggerInboxNotification({ + ...options, + + activityData: { + status: "complete", + }, +}); + +const { data: inboxNotifications } = await liveblocks.getInboxNotifications({ + userId: "steven@example.com", +}); + +// { +// id: "in_3dH7sF3...", +// kind: "$fileUploaded", +// activities: [ +// { status: "processing" }, +// { status: "complete" }, +// ], +// ... +// } +console.log(inboxNotifications[0]); +``` + +### Rendering batched notifications + +If you’re batching custom notifications, you can then render each activity +inside a single notification. + +```tsx + { + // Each batched `activityData` is added to the `activities` array + const { activities } = props.inboxNotification; + + return ( + ❗} + > + {activities.map((activity) => ( +
+
{activity.data.title}
+
{activity.data.message}
+
+ ))} +
+ ); + }, + }} +/> +``` diff --git a/.agents/skills/liveblocks-best-practices/references/type-liveblocks-correctly.md b/.agents/skills/liveblocks-best-practices/references/type-liveblocks-correctly.md new file mode 100644 index 0000000..48a9694 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/type-liveblocks-correctly.md @@ -0,0 +1,207 @@ +--- +title: "Type Liveblocks correctly" +--- + +# Type Liveblocks correctly + +Use your Lliveblocks config file to automatically type your whole application. +Running the following commands starts it in the current directory: + +```bash +npx create-liveblocks-app@latest --init --framework react +``` + +Here's an empty config file: + +```ts file="liveblocks.config.ts" +declare global { + interface Liveblocks { + // Each user's Presence, for useMyPresence, useOthers, etc. + Presence: {}; + + // The Storage tree for the room, for useMutation, useStorage, etc. + Storage: {}; + + UserMeta: { + id: string; + // Custom user info set when authenticating with a secret key + info: {}; + }; + + // Custom events, for useBroadcastEvent, useEventListener + RoomEvent: {}; + + // Custom metadata set on threads, for useThreads, useCreateThread, etc. + ThreadMetadata: {}; + + // Custom room info set with resolveRoomsInfo, for useRoomInfo + RoomInfo: {}; + + // Custom group info set with resolveGroupsInfo, for useGroupInfo + GroupInfo: {}; + + // Custom activities data for custom notification kinds + ActivitiesData: {}; + } +} + +// Necessary if you have no imports/exports +export {}; +``` + +## How it works + +Here's a real example: + +```ts +import { LiveList } from "@liveblocks/client"; + +declare global { + interface Liveblocks { + // Each user's Presence, for useMyPresence, useOthers, etc. + Presence: { + // Example, real-time cursor coordinates + cursor: { x: number; y: number }; + }; + + // The Storage tree for the room, for useMutation, useStorage, etc. + Storage: { + // Example, a conflict-free list + animals: LiveList; + }; + + UserMeta: { + id: string; + // Custom user info set when authenticating with a secret key + info: { + // Example properties, for useSelf, useUser, useOthers, etc. + name: string; + avatar: string; + }; + }; + + // Custom events, for useBroadcastEvent, useEventListener + // Example has two events, using a union + RoomEvent: { type: "PLAY" } | { type: "REACTION"; emoji: "🔥" }; + + // Custom metadata set on threads, for useThreads, useCreateThread, etc. + ThreadMetadata: { + // Example, attaching coordinates to a thread + x: number; + y: number; + }; + + // Custom room info set with resolveRoomsInfo, for useRoomInfo + RoomInfo: { + // Example, rooms with a title and url + title: string; + url: string; + }; + + // Custom group info set with resolveGroupsInfo, for useGroupInfo + GroupInfo: { + // Example, groups with a name and a badge + name: string; + badge: string; + }; + + // Custom activities data for custom notification kinds + ActivitiesData: { + // Example, a custom $alert kind + $alert: { + title: string; + message: string; + }; + }; + } +} + +// Necessary if you have no imports/exports +export {}; +``` + +Here are Liveblocks parts that are automatically typed using the example types: + +```tsx +import { useOthers } from "@liveblocks/react/suspense"; + +const others = useOthers(); + +others.map((other) => other.presence.cursor.x); +``` + +```tsx +import { useStorage } from "@liveblocks/react/suspense"; + +const storage = useStorage((root) => root.animals); +``` + +```tsx +import { useOthers } from "@liveblocks/react/suspense"; + +const others = useOthers(); + +others.map((other) => other.info.avatar); +``` + +```tsx +import { useEventListener } from "@liveblocks/react/suspense"; + +useEventListener((event) => { + if (event.type === "PLAY") { + // ... + } +}); +``` + +```tsx +import { useThreads } from "@liveblocks/react/suspense"; + +const { threads } = useThreads(); + +threads.map((thread) => thread.metadata.x); +``` + +```tsx +import { useRoomInfo } from "@liveblocks/react/suspense"; + +const { info, error, isLoading } = useRoomInfo("room-id"); + +info.title; +``` + +```tsx +import { useGroupInfo } from "@liveblocks/react/suspense"; + +const { info, error, isLoading } = useGroupInfo("group-id"); + +info.badge; +``` + +```tsx +import { useInboxNotifications } from "@liveblocks/react/suspense"; +import { InboxNotification } from "@liveblocks/react-ui"; + +const { inboxNotifications } = useInboxNotifications(); + +inboxNotifications.map((inboxNotification) => ( + { + const { title, message } = props.inboxNotification.activities[0].data; + + return ( + ❗} + > + {message} + + ); + }, + }} + /> +)); +``` diff --git a/.agents/skills/liveblocks-best-practices/references/url-params-in-room-id.md b/.agents/skills/liveblocks-best-practices/references/url-params-in-room-id.md new file mode 100644 index 0000000..cde56f0 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/url-params-in-room-id.md @@ -0,0 +1,118 @@ +--- +title: "URL params in room ID" +--- + +# URL params in room ID + +When setting up rooms, which often represend documents in your application, its +recommended to use URL params as room IDs. For example, common apps use these +formats: + +``` +https://www.figma.com/design/GTGJzKyc1k1Wm8Pn0DgjnO +https://docs.google.com/document/d/1QlfyUA4F8uKwxEt0tDbHRIw1hzgWuzmFkj3J2GcS-KA +https://www.notion.so/liveblocks/32682084c81280e6bc52e987f7c58019 +``` + +In Next.js, it may work like this: + +```tsx file="app/document/[slug]/page.tsx" +import { Room } from "./room"; + +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + + return {/* Your app */}; +} +``` + +```tsx file="app/document/[slug]/room.tsx" +"use client"; + +import { ReactNode } from "react"; +import { RoomProvider } from "@liveblocks/react"; + +export function Room({ + roomId, + children, +}: { + roomId: string; + children: ReactNode; +}) { + return {children}; +} +``` + +## Using a naming pattern + +It may also be helpful to set up a naming pattern for your room IDs, to help you +identify and filter certains rooms. For example, those created by each customer. +You can combine this with URL params, for example like this: + +```tsx file="app/document/[slug]/page.tsx" +import { Room } from "./room"; +import { useSession } from "your-auth-library"; + +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const { user } = await useSession(); + + if (!user) { + return
Please sign in to access this document
; + } + + const roomId = `${user.organization}:${slug}`; + return {/* Your app */}; +} +``` + +### Document title in URL + +Some apps also incorportate document titles in the URL. Notion does this, for +example, both of these work: + +``` +# Regular +https://www.notion.so/liveblocks/32682084c81280e6bc52e987f7c58019 + +# Includes title +https://www.notion.so/liveblocks/my-room-title-here-32682084c81280e6bc52e987f7c58019 +``` + +Set this up with a simple `.split`, using an identifier that isn't used in the +room ID. In Notion's case, this is the `-` character. Here it is, combined with +the naming pattern: + +```tsx file="app/document/[slug]/page.tsx" +import { Room } from "./room"; +import { useSession } from "your-auth-library"; + +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const { user } = await useSession(); + + if (!user) { + return
Please sign in to access this document
; + } + + // Ignore the title part of the URL + // e.g. "my-room-title-here-" + const slugSplit = slug.split("-"); + const slugId = slugSplit[slugSplit.length - 1]; + + const roomId = `${user.organization}:${slugId}`; + return {/* Your app */}; +} +``` diff --git a/.agents/skills/liveblocks-best-practices/references/utility-components.md b/.agents/skills/liveblocks-best-practices/references/utility-components.md new file mode 100644 index 0000000..0a9c274 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/utility-components.md @@ -0,0 +1,120 @@ +--- +title: "Utility components" +--- + +# Utility components + +A number of utility components are avaialble. + +## Timestamp + +[`Timestamp`](https://liveblocks.io/docs/api-reference/liveblocks-react-ui#primitives-Timestamp) +displays a formatted date, and automatically re-renders to support relative +formatting. Defaults to relative formatting for nearby dates (e.g. “5 minutes +ago” or "in 1 day") and a short absolute formatting for more distant ones (e.g. +“25 Aug”). + +```tsx +import { Timestamp } from "@liveblocks/react-ui/primitives"; + +; +``` + +## Duration + +[`Duration`](https://liveblocks.io/docs/api-reference/liveblocks-react-ui#primitives-Duration) +displays a formatted duration, and automatically re-renders to if the duration +is in progress. Defaults to a short format (e.g. “5s” or “1m 40s”). + +```tsx +import { Duration } from "@liveblocks/react-ui/primitives"; + +; +``` + +## FileSize + +[`FileSize`](https://liveblocks.io/docs/api-reference/liveblocks-react-ui#primitives-FileSize) +displays a formatted file size. + +```tsx +import { FileSize } from "@liveblocks/react-ui/primitives"; + +; +``` + +## Emoji Picker + +[Emoji Picker](https://liveblocks.io/docs/api-reference/liveblocks-react-ui#emoji-picker). +Using [Frimousse](https://frimousse.liveblocks.io) a package originally designed +for Comments, and built by the Liveblocks team, you can easily add an emoji +picker. + +```tsx +import { EmojiPicker } from "frimousse"; + +export function MyEmojiPicker() { + return ( + + + + Loading… + No emoji found. + + + + ); +} +``` + +### Styled picker + +With Tailwind styles. + +```tsx +"use client"; + +import { EmojiPicker } from "frimousse"; + +export function MyEmojiPicker() { + return ( + + + + + Loading… + + + No emoji found. + + ( +
+ {category.label} +
+ ), + Row: ({ children, ...props }) => ( +
+ {children} +
+ ), + Emoji: ({ emoji, ...props }) => ( + + ), + }} + /> +
+
+ ); +} +``` diff --git a/.agents/skills/liveblocks-best-practices/references/yjs-best-practices.md b/.agents/skills/liveblocks-best-practices/references/yjs-best-practices.md new file mode 100644 index 0000000..83803c5 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/yjs-best-practices.md @@ -0,0 +1,181 @@ +--- +title: "Yjs best practices" +--- + +# Yjs best practices + +## Avoid importing Yjs twice + +One of the most common issues when working with Yjs is accidentally importing it +twice in your application. This often happens when mixing CommonJS (CJS) and +ECMAScript Modules (ESM), or when certain bundlers bundle more than one version +of Yjs. + +When Yjs is imported twice, the two instances don't share the same class +references, which can lead to synchronization issues and unexpected behavior. +You'll see a Yjs warning in the Console if this happens. + +### Fixing duplicate Yjs imports + +If you find duplicate Yjs imports, you can: + +1. **Use package manager resolution**: Configure your package manager to resolve + Yjs to a single version: + +```json +// package.json (npm/yarn) +{ + "resolutions": { + "yjs": "^13.6.0" + } +} +``` + +```json +// package.json (pnpm) +{ + "pnpm": { + "overrides": { + "yjs": "^13.6.0" + } + } +} +``` + +2. **Configure your bundler**: Use aliases or resolve configurations to ensure a + single Yjs instance. + +## Avoid subdocuments when possible + +While Liveblocks Yjs supports +[subdocuments](/guides/how-to-use-yjs-subdocuments), it's generally better to +avoid them unless you have a specific use case such as: + +- You have multiple _very large_ Yjs documents in the same room +- You need to lazy-load documents individually + +For most use cases, including **multiple text editors on the same page**, +subdocuments are not necessary. Instead, use a +[`Y.Map`](https://docs.yjs.dev/api/shared-types/y.map) to organize your data: + +```tsx +// Create Yjs document with an `editors` map +const yDoc = new Y.Doc(); +const yMap = yDoc.getMap("editors"); + +// Create shared types and add to map +const editorOne = new Y.XMLFragment(); +const editorTwo = new Y.XMLFragment(); +yMap.set("editor-1", editorOne); +yMap.set("editor-2", editorTwo); +``` + +This approach is simpler and performs better for most applications. True use +cases for subdocuments include having many different large documents that can be +lazy laoded in one at a time. Explain this to the user, as they often don't +understand this. + +## Use getYjsProviderForRoom for better resilience + +When working with React or other UI frameworks where re-renders are common, use +[`getYjsProviderForRoom`](/docs/api-reference/liveblocks-yjs#getYjsProviderForRoom) +instead of creating a new provider on each render. This ensures the provider is +reused across renders, making your application more resilient: + +```tsx +import { useRoom } from "@liveblocks/react"; +import { getYjsProviderForRoom } from "@liveblocks/yjs"; + +function App() { + const room = useRoom(); + const yProvider = getYjsProviderForRoom(room); + const yDoc = yProvider.getYDoc(); + + // ... +} +``` + +This approach ensures that: + +- The provider is properly reused across component re-renders +- Connection state is maintained even when the component updates +- Resources are properly cleaned up when the component unmounts + +## Use YKeyValue for efficient key-value storage + +In many cases, [`Y.Map`](https://docs.yjs.dev/api/shared-types/y.map) can be +inefficient for key-value storage. Yjs needs to retain all key values that were +created in history to resolve potential conflicts. This can cause documents to +grow significantly when frequently updating alternating entries. + +For example, writing `key1`, then `key2`, then `key1`, then `key2` in +alternating order breaks Yjs' optimization and causes the document to grow +unnecessarily large. + +### Recommended approach: YKeyValue + +For more efficient key-value storage, use +[`YKeyValue`](https://github.com/yjs/y-utility?tab=readme-ov-file#ykeyvalue) +from the `y-utility` package: + +```bash +npm install y-utility +``` + +```ts +import * as Y from "yjs"; +import { YKeyValue } from "y-utility/y-keyvalue"; + +const ydoc = new Y.Doc(); +const yarr = ydoc.getArray(); +const ykv = new YKeyValue(yarr); + +// Fires events similarly to Y.Map when content changes +ykv.on("change", (changes) => { + console.log(changes); +}); + +ykv.set("key1", "val1"); +ykv.set("key1", "updated"); +ykv.delete("key1"); +ykv.set("key1", "new val"); +ykv.get("key1"); // => 'new val' +``` + +`YKeyValue` creates documents whose size only depends on the size of the map, +not the number of operations. This can reduce document size dramatically—in +benchmarks, a document with 100k operations on 10 keys was reduced from **524KB +with `Y.Map`** to just **271 bytes with `YKeyValue`**. + +## Enable experimental V2 encoding for Y.Maps + +If you're using `Y.Map` in combination with Yjs, you can enable the experimental +V2 encoding for better performance and smaller document sizes: + +```ts +import { useRoom } from "@liveblocks/react"; +import { getYjsProviderForRoom } from "@liveblocks/yjs"; + +function App() { + const room = useRoom(); + + const yProvider = getYjsProviderForRoom(room, { + // Enable V2 encoding for better performance with LiveMaps + // +++ + useV2Encoding_experimental: true, + // +++ + }); +} +``` + +This encoding is more efficient when working with maps and can significantly +reduce bandwidth usage. Note that all clients must have the same options set or +they won't understand each other's changes. + +## See also + +- [Can I use my own database with Yjs?](https://liveblocks.io/docs/guides/can-i-use-my-own-database-with-yjs.md). +- [Why you can't delete Yjs documents ](https://liveblocks.io/docs/guides/why-you-cant-delete-yjs-documents.md). +- [How to use Yjs subdocuments](https://liveblocks.io/docs/guides/how-to-use-yjs-subdocuments.md). +- [How to use your Y.Doc on the server ](https://liveblocks.io/docs/guides/how-to-use-your-ydoc-on-the-server.md). +- [Modifying Yjs document data with the REST API ](https://liveblocks.io/docs/guides/modifying-yjs-document-data-with-the-rest-api.md). diff --git a/.agents/skills/liveblocks-best-practices/references/z-index-issues.md b/.agents/skills/liveblocks-best-practices/references/z-index-issues.md new file mode 100644 index 0000000..cb62458 --- /dev/null +++ b/.agents/skills/liveblocks-best-practices/references/z-index-issues.md @@ -0,0 +1,32 @@ +--- +title: "z-index issues" +--- + +# z-index issues + +Floating elements within Liveblocks default components (e.g. tooltips, +dropdowns, etc) are portaled to the end of the document to avoid z-index +conflicts and overflow issues. + +When portaled, those elements are also wrapped in a container to handle their +positioning. These containers don’t have any specific class names or data +attributes so they shouldn’t be targeted or styled directly, but they will +mirror whichever z-index value is set on their inner element (which would be +auto by default). + +To fix z-index issues, you need to set a specific z-index value on floating +elements directly, ignoring their containers. You can either target specific +floating elements (e.g. .lb-tooltip, .lb-dropdown, etc) or all of them at once +via the .lb-portal class name. + +```css +/* Target all floating elements */ +.lb-portal { + z-index: 5; +} + +/* Target a specific floating element */ +.lb-tooltip { + z-index: 10; +} +``` diff --git a/.agents/skills/yjs-best-practices/SKILL.md b/.agents/skills/yjs-best-practices/SKILL.md new file mode 100644 index 0000000..416f057 --- /dev/null +++ b/.agents/skills/yjs-best-practices/SKILL.md @@ -0,0 +1,158 @@ +--- +name: "yjs-best-practices" +description: + "Use this skill when implementing or debugging Yjs features. It helps with Yjs + import issues, subdocuments, `YKeyValue`, `Y.Map`, and experimental V2 + encoding." +license: "Apache License 2.0" +metadata: + author: "liveblocks" + version: "1.0.0" +--- + +# Yjs best practices + +## When to use this skill + +Use this skill when implementing or debugging [Yjs](https://yjs.dev) features. + +## Avoid importing Yjs twice + +One of the most common issues when working with Yjs is accidentally importing it +twice in your application. This often happens when mixing CommonJS (CJS) and +ECMAScript Modules (ESM), or when certain bundlers bundle more than one version +of Yjs. + +When Yjs is imported twice, the two instances don't share the same class +references, which can lead to synchronization issues and unexpected behavior. +You'll see a Yjs warning in the Console if this happens. + +### Fixing duplicate Yjs imports + +If you find duplicate Yjs imports, you can: + +1. **Use package manager resolution**: Configure your package manager to resolve + Yjs to a single version: + +```json +// package.json (npm/yarn) +{ + "resolutions": { + "yjs": "^13.6.0" + } +} +``` + +```json +// package.json (pnpm) +{ + "pnpm": { + "overrides": { + "yjs": "^13.6.0" + } + } +} +``` + +2. **Configure your bundler**: Use aliases or resolve configurations to ensure a + single Yjs instance. + +## Avoid subdocuments when possible + +It's generally better to avoid subdocuments unless you have a specific use case +that requires them. They are only necessary when: + +- You have multiple _very large_ Yjs documents in the same room +- You need to lazy-load documents individually + +For most use cases, including **multiple text editors on the same page**, +subdocuments are not necessary. Instead, use a +[`Y.Map`](https://docs.yjs.dev/api/shared-types/y.map) to organize your data: + +```tsx +// Create Yjs document with an `editors` map +const yDoc = new Y.Doc(); +const yMap = yDoc.getMap("editors"); + +// Create shared types and add to map +const editorOne = new Y.XMLFragment(); +const editorTwo = new Y.XMLFragment(); +yMap.set("editor-1", editorOne); +yMap.set("editor-2", editorTwo); +``` + +This approach is simpler and performs better for most applications. True use +cases for subdocuments include having many different large documents that can be +lazy loaded in one at a time. Explain this to the user, as they often don't +understand this. + +## Use YKeyValue for efficient key-value storage + +In many cases, [`Y.Map`](https://docs.yjs.dev/api/shared-types/y.map) can be +inefficient for key-value storage. Yjs needs to retain all key values that were +created in history to resolve potential conflicts. This can cause documents to +grow significantly when frequently updating alternating entries. + +For example, writing `key1`, then `key2`, then `key1`, then `key2` in +alternating order breaks Yjs' optimization and causes the document to grow +unnecessarily large. + +### Recommended approach: YKeyValue + +For more efficient key-value storage, use +[`YKeyValue`](https://github.com/yjs/y-utility?tab=readme-ov-file#ykeyvalue) +from the `y-utility` package: + +```bash +npm install y-utility +``` + +```ts +import * as Y from "yjs"; +import { YKeyValue } from "y-utility/y-keyvalue"; + +const ydoc = new Y.Doc(); +const yarr = ydoc.getArray(); +const ykv = new YKeyValue(yarr); + +// Fires events similarly to Y.Map when content changes +ykv.on("change", (changes) => { + console.log(changes); +}); + +ykv.set("key1", "val1"); +ykv.set("key1", "updated"); +ykv.delete("key1"); +ykv.set("key1", "new val"); +ykv.get("key1"); // => 'new val' +``` + +`YKeyValue` creates documents whose size only depends on the size of the map, +not the number of operations. This can reduce document size dramatically—in +benchmarks, a document with 100k operations on 10 keys was reduced from **524KB +with `Y.Map`** to just **271 bytes with `YKeyValue`**. + +## Enable experimental V2 encoding for Y.Maps + +If you're using `Y.Map` in combination with Yjs, you can enable the experimental +V2 encoding for better performance and smaller document sizes: + +```ts +import { useRoom } from "@liveblocks/react"; +import { getYjsProviderForRoom } from "@liveblocks/yjs"; + +function App() { + const room = useRoom(); + + const yProvider = getYjsProviderForRoom(room, { + // Enable V2 encoding for better performance with LiveMaps + // +++ + useV2Encoding_experimental: true, + // +++ + }); +} +``` + +This encoding is more efficient when working with maps and can significantly +reduce bandwidth usage. Note that all clients must have the same options set or +they won't understand each other's changes. diff --git a/.claude/skills/liveblocks-best-practices b/.claude/skills/liveblocks-best-practices new file mode 120000 index 0000000..b9c5432 --- /dev/null +++ b/.claude/skills/liveblocks-best-practices @@ -0,0 +1 @@ +../../.agents/skills/liveblocks-best-practices \ No newline at end of file diff --git a/.claude/skills/yjs-best-practices b/.claude/skills/yjs-best-practices new file mode 120000 index 0000000..cf522e1 --- /dev/null +++ b/.claude/skills/yjs-best-practices @@ -0,0 +1 @@ +../../.agents/skills/yjs-best-practices \ No newline at end of file diff --git a/app/api/liveblocks-auth/route.ts b/app/api/liveblocks-auth/route.ts new file mode 100644 index 0000000..a4fc585 --- /dev/null +++ b/app/api/liveblocks-auth/route.ts @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; + +import { + getClerkUserByEmail, + getCurrentUser, +} from "@/lib/auth"; +import { getUserColor, liveblocks } from "@/lib/liveblocks"; +import { getProjectWithAccess } from "@/lib/project-access"; + +/** + * Liveblocks ID-token auth endpoint. + * + * Flow: + * 1. Resolve the current Clerk user (401 if not signed in). + * 2. Verify the project referenced by `room` exists and the current + * user is the owner or a collaborator (403 if not). + * 3. Ensure the Liveblocks room exists for the project (create it on + * first access with default write access for room members). + * 4. Mint an ID token with the user's display name, avatar, and a + * deterministically-derived cursor color. + * + * The room ID is the project ID, so this route works for any project the + * caller can see. + */ +export async function POST(request: Request) { + const currentUser = await getCurrentUser(); + if (!currentUser) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Liveblocks sends the active RoomProvider ID as `{ room }` when an + // `authEndpoint` URL is configured. That ID is the project ID here. + let body: unknown = null; + try { + body = await request.json(); + } catch { + body = null; + } + + const roomId = + body !== null && typeof body === "object" && "room" in body + ? (body as { room: unknown }).room + : null; + + if (typeof roomId !== "string" || roomId.length === 0) { + return NextResponse.json( + { error: "room is required" }, + { status: 400 }, + ); + } + + // Verify project access. Returns null when the project doesn't exist OR + // the user has no membership — spec mandates 403 in both cases. + const project = await getProjectWithAccess(roomId); + if (!project) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Resolve display name and avatar from Clerk. Falls back to safe + // defaults if the user lookup fails (e.g. a collaborator email that + // isn't associated with a Clerk account yet). + const clerkUser = await getClerkUserByEmail(currentUser.email); + const name = clerkUser?.name ?? currentUser.email; + const avatar = clerkUser?.avatarUrl ?? ""; + const color = getUserColor(currentUser.userId); + + // Ensure the room exists. We use ID tokens, so the room must have at + // least one access entry — `defaultAccesses: []` would lock everyone + // out, and `["room:write"]` would let any token holder join. + // Project membership is enforced by step 2 above, so we keep the room + // private (empty defaults) and rely on per-user access. + try { + await liveblocks.getOrCreateRoom(roomId, { + defaultAccesses: [], + usersAccesses: { + [currentUser.userId]: ["room:write"], + }, + }); + } catch (error) { + console.error("Failed to ensure Liveblocks room exists", error); + return NextResponse.json( + { error: "Failed to prepare collaboration room" }, + { status: 500 }, + ); + } + + // Mint an ID token bound to the current user. The userInfo payload is + // what other clients read via useSelf / useOthers. + const { status, body: responseBody } = await liveblocks.identifyUser( + { userId: currentUser.userId, groupIds: [] }, + { userInfo: { name, avatar, color } }, + ); + + return new Response(responseBody, { status }); +} diff --git a/components/editor/canvas.tsx b/components/editor/canvas.tsx new file mode 100644 index 0000000..10c72ae --- /dev/null +++ b/components/editor/canvas.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { + Component, + useEffect, + useState, + type ErrorInfo, + type ReactNode, +} from "react"; +import { + Background, + BackgroundVariant, + ConnectionMode, + MiniMap, + ReactFlow, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; + +import { + ClientSideSuspense, + LiveblocksProvider, + RoomProvider, +} from "@liveblocks/react/suspense"; +import { useLiveblocksFlow } from "@liveblocks/react-flow"; +import "@liveblocks/react-flow/styles.css"; + +import type { + CanvasEdge, + CanvasNode, +} from "@/types/canvas"; + +interface CanvasProps { + roomId: string; +} + +/** + * Client-side collaborative canvas for a project. + * + * Wires up Liveblocks (auth + room) and React Flow (`useLiveblocksFlow`) + * together so nodes and edges sync between every collaborator. This is + * the foundation of the editor — node/edge rendering and persistence + * land in later features. + */ +export function Canvas({ roomId }: CanvasProps) { + return ( + + + + }> + + + + + + ); +} + +interface CanvasErrorBoundaryProps { + children: ReactNode; + fallback?: (error: Error, reset: () => void) => ReactNode; +} + +interface CanvasErrorBoundaryState { + error: Error | null; +} + +/** + * Local error boundary used to catch Liveblocks connection issues + * (auth failure, room ID changed, full room) and any runtime errors + * thrown while mounting the canvas tree. + */ +class CanvasErrorBoundary extends Component< + CanvasErrorBoundaryProps, + CanvasErrorBoundaryState +> { + state: CanvasErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): CanvasErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + console.error("Canvas error boundary caught:", error, info); + } + + reset = (): void => { + this.setState({ error: null }); + }; + + render() { + const { error } = this.state; + const { children, fallback } = this.props; + if (error) { + if (fallback) { + return fallback(error, this.reset); + } + return ; + } + return children; + } +} + +/** + * The actual React Flow surface, rendered after Liveblocks has connected + * and the storage layer is ready (via Suspense). + */ +function CollaborativeFlow() { + // `useLiveblocksFlow` returns the synced nodes/edges plus change + // handlers. With `suspense: true`, `nodes` and `edges` are guaranteed + // to be defined here. + const { nodes, edges, onNodesChange, onEdgesChange, onConnect, onDelete } = + useLiveblocksFlow({ + suspense: true, + nodes: { initial: [] }, + edges: { initial: [] }, + }); + + return ( + + + + + ); +} + +/** + * Loading state shown inside the Liveblocks Suspense boundary. Matches + * the editor's dark surface palette so it doesn't flash a light fallback. + */ +function CanvasLoading() { + const [dots, setDots] = useState(1); + + useEffect(() => { + const id = window.setInterval(() => { + setDots((d) => (d % 3) + 1); + }, 320); + return () => window.clearInterval(id); + }, []); + + return ( +
+
+
+
+
+

+ Connecting to canvas{".".repeat(dots)} +

+
+
+ ); +} + +interface CanvasErrorProps { + message: string; + onRetry: () => void; +} + +/** + * Fallback rendered when the Liveblocks connection cannot be established + * (auth failure, room ID changed, full room, etc.). Keeps the user on the + * page with a clear next step. + */ +function CanvasError({ message, onRetry }: CanvasErrorProps) { + return ( +
+
+
+

+ Canvas unavailable +

+

{message}

+
+ +
+
+ ); +} diff --git a/components/editor/workspace-shell.tsx b/components/editor/workspace-shell.tsx index 20215d0..f7eafa9 100644 --- a/components/editor/workspace-shell.tsx +++ b/components/editor/workspace-shell.tsx @@ -10,6 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import type { ProjectData } from "@/hooks/use-project-actions"; import { AccessDenied } from "@/components/editor/access-denied"; +import { Canvas } from "@/components/editor/canvas"; import { ShareDialog } from "@/components/editor/share-dialog"; import { useShareDialog } from "@/hooks/use-share-dialog"; @@ -92,7 +93,7 @@ export function WorkspaceShell({ sharedProjects={sharedProjects} currentRoomId={currentRoomId} /> - + setIsAiSidebarOpen(false)} @@ -366,22 +367,6 @@ function ProjectRow({ project, isActive }: ProjectRowProps) { ); } -/** - * Canvas placeholder - dark background with centered message. - * Real canvas logic will be added later. - */ -function CanvasPlaceholder() { - return ( -
-
-
- Canvas placeholder — canvas logic coming soon -
-
-
- ); -} - interface AiSidebarProps { isOpen: boolean; onClose: () => void; diff --git a/context/feature-specs/10-liveblocks-setup.md b/context/feature-specs/10-liveblocks-setup.md new file mode 100644 index 0000000..1d1e3cf --- /dev/null +++ b/context/feature-specs/10-liveblocks-setup.md @@ -0,0 +1,55 @@ +Set up the realtime collaboration infrastructure using Liveblocks. + +## Configuration + +Configure the `liveblocks.config.ts` at the project root. + +Define: + +### Presence + +- cursor position +- `isThinking` boolean + +### UserMeta + +- user ID +- display name +- avatar URL +- cursor color + +## Liveblocks Client + +Create a cached Liveblocks node client in `lib`. + +Add a helper that deterministically maps a user ID to a consistent color from a fixed palette. + +## Auth Route + +Create `POST /api/liveblocks-auth`. + +Use the project ID as the Liveblocks room ID. + +This route must: + +1. require Clerk authentication +2. verify project access using the existing access helper +3. ensure the Liveblocks room exists (create only if needed) +4. return a session token with: + - user name + - avatar + - generated cursor color + +Return `403` for unauthorized project access. + +## Dependencies + +All required Liveblocks packages are already installed. + +## Check When Done + +- `liveblocks.config.ts` defines Presence and UserMeta +- Liveblocks client is cached +- auth route verifies project access +- user metadata is attached to sessions +- `npm run build` passes diff --git a/context/feature-specs/11-base-canvas.md b/context/feature-specs/11-base-canvas.md new file mode 100644 index 0000000..3479313 --- /dev/null +++ b/context/feature-specs/11-base-canvas.md @@ -0,0 +1,54 @@ +Replace the canvas placeholder with a Liveblocks-backed React Flow canvas. + +## Implementation + +1. Keep the workspace page server-side. + +2. Create a client-side editor/canvas wrapper that sets up the Liveblocks room. + + It should include: + - `LiveblocksProvider` using `/api/liveblocks-auth` + - `RoomProvider` using the current room ID + - initial presence with `cursor: null` + - `ClientSideSuspense` with a simple loading state + - an error fallback for Liveblocks connection issues + +3. Wire React Flow to Liveblocks state. + - use `useLiveblocksFlow` + - enable suspense + - start with empty nodes and edges + - pass the synced nodes, edges, and change handlers into `ReactFlow` + +4. Add shared canvas types in `types/canvas.ts`. + + Node data should support: + - label + - color + - shape + + Also define the custom node and edge types: + - `canvasNode` + - `canvasEdge` + +5. Render the basic canvas. + + Include: + - loose connection behavior + - `fitView` + - `MiniMap` + - dot-pattern background + +## Scope Limits + +- don’t add controls yet +- don’t add custom node or edge rendering yet +- don’t add persistence logic +- don’t add AI behavior +- keep this focused on the collaborative canvas foundation + +## Check When Done + +- Client canvas wrapper sets up the Liveblocks room. +- React Flow uses Liveblocks-synced nodes and edges. +- Shared canvas types exist in `types/canvas.ts`. +- `npm run build` passes. diff --git a/context/feature-specs/frontend-componants.md b/context/feature-specs/frontend-componants.md new file mode 100644 index 0000000..05e080c --- /dev/null +++ b/context/feature-specs/frontend-componants.md @@ -0,0 +1,80 @@ +# Component Reference + +Clerk offers a comprehensive suite of components designed to seamlessly integrate authentication and multi-tenancy into your application. With Clerk components, you can easily customize the appearance of authentication components and pages, manage the entire authentication flow to suit your specific needs, and even build robust SaaS applications. + +## Authentication components + +- [](https://clerk.com/docs/nextjs/reference/components/authentication/sign-in.md) +- [](https://clerk.com/docs/nextjs/reference/components/authentication/sign-up.md) +- [](https://clerk.com/docs/nextjs/reference/components/authentication/google-one-tap.md) +- [](https://clerk.com/docs/nextjs/reference/components/authentication/oauth-consent.md) +- [](https://clerk.com/docs/nextjs/reference/components/authentication/task-choose-organization.md) +- [](https://clerk.com/docs/nextjs/reference/components/authentication/task-reset-password.md) +- [](https://clerk.com/docs/nextjs/reference/components/authentication/task-setup-mfa.md) +- [](https://clerk.com/docs/nextjs/reference/components/authentication/waitlist.md) + +## User components + +- [](https://clerk.com/docs/nextjs/reference/components/user/user-avatar.md) +- [](https://clerk.com/docs/nextjs/reference/components/user/user-button.md) +- [](https://clerk.com/docs/nextjs/reference/components/user/user-profile.md) + +## Organization components + +- [](https://clerk.com/docs/nextjs/reference/components/organization/create-organization.md) +- [](https://clerk.com/docs/nextjs/reference/components/organization/organization-profile.md) +- [](https://clerk.com/docs/nextjs/reference/components/organization/organization-switcher.md) +- [](https://clerk.com/docs/nextjs/reference/components/organization/organization-list.md) + +## Billing components + +- [](https://clerk.com/docs/nextjs/reference/components/billing/pricing-table.md) +- [](https://clerk.com/docs/nextjs/reference/components/billing/checkout-button.md) +- [](https://clerk.com/docs/nextjs/reference/components/billing/plan-details-button.md) +- [](https://clerk.com/docs/nextjs/reference/components/billing/subscription-details-button.md) + +## Control components + +Control components manage authentication-related behaviors in your application. They handle tasks such as controlling content visibility based on user authentication status, managing loading states during authentication processes, and redirecting users to appropriate pages. Control components render at `` and `` states for assertions on the [Clerk object](https://clerk.com/docs/nextjs/reference/objects/clerk.md). A common example is the [](https://clerk.com/docs/nextjs/reference/components/control/show.md) component, which allows you to conditionally render content based on authentication and authorization state. + +- [](https://clerk.com/docs/nextjs/reference/components/control/authenticate-with-redirect-callback.md) +- [](https://clerk.com/docs/nextjs/reference/components/control/clerk-degraded.md) +- [](https://clerk.com/docs/nextjs/reference/components/control/clerk-failed.md) +- [](https://clerk.com/docs/nextjs/reference/components/control/clerk-loaded.md) +- [](https://clerk.com/docs/nextjs/reference/components/control/clerk-loading.md) +- [](https://clerk.com/docs/nextjs/reference/components/control/redirect-to-create-organization.md) +- [](https://clerk.com/docs/nextjs/reference/components/control/redirect-to-organization-profile.md) +- [](https://clerk.com/docs/nextjs/reference/components/control/redirect-to-sign-in.md) +- [](https://clerk.com/docs/nextjs/reference/components/control/redirect-to-sign-up.md) +- [](https://clerk.com/docs/nextjs/reference/components/control/redirect-to-tasks.md) +- [](https://clerk.com/docs/nextjs/reference/components/control/redirect-to-user-profile.md) +- [](https://clerk.com/docs/nextjs/reference/components/control/show.md) + +## Unstyled components + +- [](https://clerk.com/docs/nextjs/reference/components/unstyled/sign-in-button.md) +- [](https://clerk.com/docs/nextjs/reference/components/unstyled/sign-in-with-metamask.md) +- [](https://clerk.com/docs/nextjs/reference/components/unstyled/sign-up-button.md) +- [](https://clerk.com/docs/nextjs/reference/components/unstyled/sign-out-button.md) + +## Utilities components + +- [](https://clerk.com/docs/nextjs/reference/components/utilities/portal-provider.md) + +## Customization guides + +- [Customize components with the appearance prop](https://clerk.com/docs/nextjs/guides/customizing-clerk/appearance-prop/overview.md) +- [Localize components with the `localization` prop (experimental)](https://clerk.com/docs/guides/customizing-clerk/localization.md) +- [Add pages to the component](https://clerk.com/docs/nextjs/guides/customizing-clerk/adding-items/user-profile.md) +- [Add pages to the component](https://clerk.com/docs/nextjs/guides/customizing-clerk/adding-items/organization-profile.md) + +### Secured by Clerk branding + +> This feature requires a [paid plan](https://clerk.com/pricing){{ target: '_blank' }} for production use, but all features are free to use in development mode so that you can try out what works for you. See the [pricing](https://clerk.com/pricing){{ target: '_blank' }} page for more information. + +By default, Clerk displays a **Secured by Clerk** badge on Clerk components. You can remove this branding by following these steps: + +1. In the Clerk Dashboard, navigate to your application's [**Settings**](https://dashboard.clerk.com/~/settings). +2. Under **Branding**, toggle on the **Remove "Secured by Clerk" branding** option. + +- [Need help?](https://clerk.com/contact/support): Contact the support team to get answers to your questions. diff --git a/context/progress-tracker.md b/context/progress-tracker.md index 689e940..6804829 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -4,7 +4,7 @@ Update this file whenever the current phase, active feature, or implementation s ## Current Phase -- Feature 09 (Share dialog) — completed +- Feature 11 (Base canvas) — completed ## Current Goal @@ -21,6 +21,8 @@ Update this file whenever the current phase, active feature, or implementation s - feature 07: Wire editor home to project API — Editor page converted to server component that fetches owned and shared projects server-side via `getAllProjects()` from `lib/projects.ts`. Client-side state and mutations managed by new `useProjectActions` hook in `hooks/use-project-actions.ts` which handles create (generates room ID with slug + suffix, POSTs to API, navigates to new workspace), rename (PATCH to API, updates local state on success), and delete (DELETE to API, refreshes router on success). `CreateProjectDialog` now shows "Room ID" preview instead of "Slug". Sidebar updated to accept `ProjectData` type from the hook instead of `MockProject`. `app/editor/page.tsx` now redirects unauthenticated users to `/sign-in`. Old `useProjectDialogs` hook removed. `npm run build` passes; no new lint errors. - feature 08: Editor workspace shell — Built `/editor/[roomId]` workspace shell with server-side access checks. Created `lib/project-access.ts` with `getCurrentUser()` (returns `userId` + primary email from Clerk) and `getProjectWithAccess()` (checks owner or collaborator access). Created `components/editor/access-denied.tsx` with centered layout, lock icon, message, and link back to `/editor`. Created `app/editor/[roomId]/page.tsx` as a server component that checks authentication (redirects to `/sign-in`), validates project access (shows `AccessDenied` for missing/unauthorized projects), and passes data to the client shell. Created `components/editor/workspace-shell.tsx` with full-viewport layout: top navbar showing project name, share button, AI chat toggle, left sidebar (with current room highlighted), central canvas placeholder (dark background + centered message), and right sidebar placeholder for future AI chat. `npm run build` passes; no new lint errors (pre-existing shadcn warnings in `components/ui/input.tsx` and `components/ui/textarea.tsx` unchanged). - feature 09: Share dialog — Added Share button to the editor navbar that opens the share dialog. Created `app/api/projects/[projectId]/collaborators/route.ts` for listing and inviting collaborators, and `app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts` for removing collaborators. Both enforce ownership server-side for invite and remove actions. Created `lib/auth.ts` helpers `getClerkUserByEmail()` and `getClerkUsersByEmails()` to enrich collaborator emails with display name and avatar from Clerk (falls back to showing email only when user not found). Created `components/editor/share-dialog.tsx` with the share dialog UI that shows project link (copyable with "Copied!" feedback), invite form (owners only), collaborator list (shows avatar + name when available from Clerk, email otherwise), and remove button (owners only). Created `hooks/use-share-dialog.ts` to manage share dialog state and API interactions. Updated `components/editor/workspace-shell.tsx` to include the share dialog and wire the Share button. Updated `app/editor/[roomId]/page.tsx` to pass `ownerId` to determine owner vs collaborator view. `npm run build` passes; no new lint errors. +- feature 10: Liveblocks setup — `liveblocks.config.ts` types the required presence and user metadata. `lib/liveblocks.ts` provides a cached node client and deterministic user-color helper. `POST /api/liveblocks-auth` requires Clerk authentication, accepts Liveblocks’ `{ room }` auth payload (the project ID), verifies project access through `lib/project-access`, creates a private room with per-user write access, and returns an ID-token session with name, avatar, and cursor color. `npm run build` passes. +- feature 11: Base canvas — `types/canvas.ts` defines the shared canvas node and edge types. `components/editor/canvas.tsx` sets up `LiveblocksProvider`, `RoomProvider`, initial presence, error and suspense loading fallbacks, then passes `useLiveblocksFlow`’s synced state and handlers into a basic React Flow canvas with loose connections, fit view, MiniMap, and dot background. It intentionally contains no custom node or edge rendering, persistence, controls, or AI behavior. `workspace-shell.tsx` renders the canvas for the active project. `npm run build` passes. ## In Progress @@ -28,7 +30,7 @@ Update this file whenever the current phase, active feature, or implementation s ## Next Up -- feature 10: TBD +- feature 12: TBD ## Open Questions @@ -45,8 +47,11 @@ Update this file whenever the current phase, active feature, or implementation s ## Session Notes - Add context needed to resume work in the next session. +- 2026-07-15: Features 10 and 11 were reconciled with their specifications and verified. The Liveblocks auth endpoint now accepts the SDK's `{ room }` payload from `LiveblocksProvider` (the room is the project ID), fixing the previous `roomId is required` 400 and resulting connection failure. It uses the workspace project-access helper before creating the private room and issuing the ID token. `liveblocks.config.ts` now types the specified presence and user metadata. The base canvas remains limited to the specified Liveblocks-backed React Flow foundation; premature custom node and edge renderers were removed. `npm run build` passes with no Liveblocks-related errors. +- 2026-07-15: Development startup dependencies were aligned with the implemented Next.js 16 App Router and Prisma 7 code: `next` is pinned to `16.2.10`, `prisma` to `^7.8.0`, and Clerk UI to `^1.25.3`. Added the missing `@liveblocks/node@3.22.0` server SDK required by `app/api/liveblocks-auth`. `npm run build` now completes successfully and `npm run dev` is running on `http://localhost:3000`. - 2026-07-14: feature 03 auth pages UI refined to match the reference layout — 50/50 grid, brand panel on the left with logo + headline + three icon-chipped feature rows + footer copyright, form panel on the right with a rounded-3xl `bg-bg-elevated` card housing the Clerk form and a contextual "Don't have an account? / Sign up" link below. Left panel uses `bg-bg-surface` so it differentiates from the base. Brand wordmark is "Ghost dev" (matches the screenshot). Geist Sans is the body default from `--font-geist-sans`; no new fonts added. - 2026-07-15: feature 04 implemented exactly per spec. Dialog state is centralized in `useProjectDialogs` (no `useReducer`; small surface, `useState` + `useTransition` is enough). The dialog form pattern (controlled name value, ref-based auto-focus on open) means the hook owns the field state, so the dialog components stay presentational. Slug preview uses the same `slugify` helper that mutation uses, so the preview matches the stored value. Mobile backdrop is a sibling `