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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/scripts/setup-vercel-example.sh
Original file line number Diff line number Diff line change
Expand Up @@ -515,10 +515,10 @@ add_manual_env_vars_to_vercel() {
((${#manual_env_names[@]} == 0)) && return

# Any non-Liveblocks variable in `.env.example` is added as an empty placeholder
# for preview deployments. Their values must be filled in manually on Vercel later.
# for preview and production deployments. Their values must be filled in manually on Vercel later.
for manual_env_name in "${manual_env_names[@]}"; do
vercel_request \
"Adding placeholder env var ${manual_env_name} to Vercel preview" \
"Adding placeholder env var ${manual_env_name} to Vercel" \
"POST" \
"$(vercel_url "/v10/projects/${vercel_project_id}/env")" \
"$(jq -n \
Expand All @@ -527,7 +527,7 @@ add_manual_env_vars_to_vercel() {
key: $key,
value: "",
type: "encrypted",
target: ["preview"]
target: ["preview", "production"]
}')" \
>/dev/null
done
Expand Down Expand Up @@ -698,7 +698,7 @@ write_success_summary() {
fi
echo
if ((${#manual_env_names[@]} > 0)); then
echo "Added to Vercel for preview as empty placeholders (fill in their values manually):"
echo "Added to Vercel for preview and production as empty placeholders (fill in their values manually):"
echo
for manual_env_name in "${manual_env_names[@]}"; do
echo "- \`${manual_env_name}\`"
Expand Down
8 changes: 8 additions & 0 deletions examples/nextjs-ai-spreadsheet/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# https://liveblocks.io/dashboard/apikeys
LIVEBLOCKS_SECRET_KEY=sk_xxx

# https://liveblocks.io/dashboard/webhooks - `commentCreated` webhook event at /api/liveblocks-webhook
LIVEBLOCKS_WEBHOOK_SECRET_KEY=

# https://vercel.com/docs/ai-gateway
AI_GATEWAY_API_KEY=
2 changes: 2 additions & 0 deletions examples/nextjs-ai-spreadsheet/.envrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
source_up
layout node
12 changes: 12 additions & 0 deletions examples/nextjs-ai-spreadsheet/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.DS_Store
node_modules
.env
.env.*
!.env.example
*.tsbuildinfo
.vercel
.next
out
next-env.d.ts
# Turborepo
.turbo
11 changes: 11 additions & 0 deletions examples/nextjs-ai-spreadsheet/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"semi": true,
"tabWidth": 2,
"useTabs": false,
"singleQuote": false,
"jsxSingleQuote": false,
"arrowParens": "always",
"bracketSpacing": true,
"bracketSameLine": false,
"trailingComma": "es5"
}
104 changes: 104 additions & 0 deletions examples/nextjs-ai-spreadsheet/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<p align="center">
<a href="https://liveblocks.io#gh-light-mode-only">
<img src="https://raw.githubusercontent.com/liveblocks/liveblocks/main/.github/assets/header-light.svg" alt="Liveblocks" />
</a>
<a href="https://liveblocks.io#gh-dark-mode-only">
<img src="https://raw.githubusercontent.com/liveblocks/liveblocks/main/.github/assets/header-dark.svg" alt="Liveblocks" />
</a>
</p>

# Realtime AI spreadsheet

<p>
<a href="https://liveblocks.io/examples/nextjs-ai-spreadsheet">
<img src="https://img.shields.io/badge/live%20preview-message?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE2Ljg0OSA0Ljc1SDBsNC44NDggNS4wNzV2Ny4wMDhsMTItMTIuMDgzWk03LjE1IDE5LjI1SDI0bC00Ljg0OS01LjA3NVY3LjE2N2wtMTIgMTIuMDgzWiIgZmlsbD0iI2ZmZiIvPjwvc3ZnPg==&color=333" alt="Live Preview" />
</a>
<a href="https://codesandbox.io/s/github/liveblocks/liveblocks/tree/main/examples/nextjs-ai-spreadsheet">
<img src="https://img.shields.io/badge/open%20in%20codesandbox-message?style=flat&logo=codesandbox&color=333&logoColor=fff" alt="Open in CodeSandbox" />
</a>
<img src="https://img.shields.io/badge/react-message?style=flat&logo=react&color=0bd&logoColor=fff" alt="React" />
<img src="https://img.shields.io/badge/next.js-message?style=flat&logo=next.js&color=07f&logoColor=fff" alt="Next.js" />
</p>

This example shows how to build a realtime, multiplayer spreadsheet with an AI
that edits the grid, using
[Liveblocks](https://liveblocks.io),
[Handsontable](https://handsontable.com/),
[Next.js](https://nextjs.org/), and the [Vercel AI SDK](https://ai-sdk.dev/).

The grid is backed by Liveblocks Storage, so cells, formatting, column/row sizes,
and order all sync instantly to everyone — along with live selection presence and
per-cell comment threads. Everything is addressed by stable ids, so moving,
sorting, and inserting or deleting rows and columns never breaks comments,
formatting, or presence, and every user sees the same order. The AI lives in a
[Feeds](https://liveblocks.io/docs/collaboration-features/ai-collaboration)-based
chat: it edits the spreadsheet from the server with `@liveblocks/node`
(`mutateStorage`) and shows its live selection with `setPresence`, streaming both
its reply and the grid edits as it works.

## Getting started

Run the following command to try this example locally:

```bash
npx create-liveblocks-app@latest --example nextjs-ai-spreadsheet --api-key
```

This will download the example and ask permission to open your browser, enabling
you to automatically get your API key from your
[liveblocks.io](https://liveblocks.io) account.

### Manual setup

<details><summary>Read more</summary>

<p></p>

Alternatively, you can set up your project manually:

- Install all dependencies with `npm install`
- Create an account on [liveblocks.io](https://liveblocks.io/dashboard)
- Copy your **secret** key from the
[dashboard](https://liveblocks.io/dashboard/apikeys)
- Create an `.env.local` file and add your **secret** key as the
`LIVEBLOCKS_SECRET_KEY` environment variable
- Add an `AI_GATEWAY_API_KEY` from the
[Vercel AI Gateway](https://vercel.com/docs/ai-gateway). This is required for
the AI chat — it needs a real, tool-calling model to edit the spreadsheet.
- Run `npm run dev` and go to [http://localhost:3000](http://localhost:3000)

To see the realtime sync, open the page in two browser tabs and edit a cell, drag
a row, or ask the AI to fill in some data — it appears instantly in both.

</details>

### Deploy on Vercel

<details><summary>Read more</summary>

<p></p>

To both deploy on [Vercel](https://vercel.com), and run the example locally, use
the following command:

```bash
npx create-liveblocks-app@latest --example nextjs-ai-spreadsheet --vercel
```

This will download the example and ask permission to open your browser, enabling
you to deploy to Vercel.

</details>

### Develop on CodeSandbox

<details><summary>Read more</summary>

<p></p>

After forking
[this example](https://codesandbox.io/s/github/liveblocks/liveblocks/tree/main/examples/nextjs-ai-spreadsheet)
on CodeSandbox, create the `LIVEBLOCKS_SECRET_KEY` environment variable as a
[secret](https://codesandbox.io/docs/secrets).

</details>
90 changes: 90 additions & 0 deletions examples/nextjs-ai-spreadsheet/app/CellThreadContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"use client";

import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useThreads } from "@liveblocks/react/suspense";
import type { ThreadData } from "@liveblocks/client";
import { cellKey } from "@/liveblocks.config";
import { useSelectionValue } from "./SelectionContext";

export type OpenCell = { rowId: string; colId: string } | null;

type CellThreadContextValue = {
getThread: (rowId: string, colId: string) => ThreadData | undefined;
// The cell whose thread/composer should be open (e.g. after submitting a new
// comment, or when the toolbar "+ Comment" button is pressed).
openCell: OpenCell;
setOpenCell: (openCell: OpenCell) => void;
};

const CellThreadContext = createContext<CellThreadContextValue | null>(null);

export function CellThreadProvider({ children }: { children: ReactNode }) {
const { threads } = useThreads();
const [openCell, setOpenCell] = useState<OpenCell>(null);
const selection = useSelectionValue();

// Index the most recent thread per cell for O(1) lookups in each renderer.
// Resolved threads are hidden, so they drop out of the marker, the open logic,
// and the overlay.
const byCell = useMemo(() => {
const map = new Map<string, ThreadData>();
for (const thread of threads) {
if (thread.resolved) {
continue;
}
const { rowId, colId } = thread.metadata;
if (rowId && colId) {
map.set(cellKey(rowId, colId), thread);
}
}
return map;
}, [threads]);

// Single-click to open: when the selected (anchor) cell already has a thread,
// open it. Keyed on the live selection value only, so it fires once per
// selection change — it won't reopen after the user closes the thread, and
// doesn't fight the grid's selection dedupe. `byCell` is read via a ref to
// avoid re-running on unrelated thread updates.
const byCellRef = useRef(byCell);
byCellRef.current = byCell;
useEffect(() => {
if (!selection) {
return;
}
const { anchor } = selection;
if (byCellRef.current.has(cellKey(anchor.rowId, anchor.colId))) {
setOpenCell(anchor);
}
}, [selection]);

const value = useMemo<CellThreadContextValue>(
() => ({
getThread: (rowId, colId) => byCell.get(cellKey(rowId, colId)),
openCell,
setOpenCell,
}),
[byCell, openCell]
);

return (
<CellThreadContext.Provider value={value}>
{children}
</CellThreadContext.Provider>
);
}

export function useCellThread(): CellThreadContextValue {
const context = useContext(CellThreadContext);
if (!context) {
throw new Error("useCellThread must be used within a CellThreadProvider");
}
return context;
}
Loading
Loading