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
2 changes: 2 additions & 0 deletions examples/nextjs-comments-handsontable/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# https://liveblocks.io/dashboard/apikeys
LIVEBLOCKS_SECRET_KEY=
2 changes: 2 additions & 0 deletions examples/nextjs-comments-handsontable/.envrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
source_up
layout node
10 changes: 10 additions & 0 deletions examples/nextjs-comments-handsontable/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.DS_Store
node_modules
.env
.env.*
!.env.example
*.tsbuildinfo
.vercel
.next
out
next-env.d.ts
11 changes: 11 additions & 0 deletions examples/nextjs-comments-handsontable/.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"
}
57 changes: 57 additions & 0 deletions examples/nextjs-comments-handsontable/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<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>

# Handsontable commenting

<p>
<a href="https://liveblocks.io/examples/handsontable-comments/nextjs-comments-handsontable/preview">
<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-comments-handsontable">
<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 add commenting to your
[Handsontable](https://www.handsontable.com/) table with
[Liveblocks](https://liveblocks.io) and [Next.js](https://nextjs.org/).

<img src="https://raw.githubusercontent.com/liveblocks/liveblocks/main/.github/assets/examples/comments-table.png" width="536" alt="Collaborative Text Editor" />

## Getting started

Run the following command to try this example locally:

```bash
npx create-liveblocks-app@latest --example nextjs-comments-handsontable --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
- Run `npm run dev` and go to [http://localhost:3000](http://localhost:3000)

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

import { useState, createContext, useContext } from "react";
import { useThreads } from "@liveblocks/react/suspense";
import { ThreadData } from "@liveblocks/client";

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

type CellThreadContextValue = {
threads: ThreadData[];
openCell: OpenCell;
setOpenCell: (openCell: OpenCell) => void;
};

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

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

return (
<CellThreadContext.Provider value={{ threads, openCell, setOpenCell }}>
{children}
</CellThreadContext.Provider>
);
}

export function useCellThread(): CellThreadContextValue {
const context = useContext(CellThreadContext);
if (!context) {
throw new Error("useCellThread must be used within CellThreadProvider");
}
return context;
}
144 changes: 144 additions & 0 deletions examples/nextjs-comments-handsontable/app/CommentCell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"use client";

import {
CommentPin,
FloatingComposer,
FloatingThread,
Icon,
} from "@liveblocks/react-ui";
import type { HotRendererProps } from "@handsontable/react-wrapper";
import { useSelf } from "@liveblocks/react";
import { CSSProperties, useState } from "react";
import { useCellThread } from "./CellThreadContext";

// Wrapper around the comment pin cell
export function CommentCell({
instance,
row,
col,
prop,
value,
}: HotRendererProps) {
const columnId = String(prop);
const rowId = String(instance.getDataAtRowProp(row, "id") ?? "");

if (!rowId || !columnId) {
return null;
}

return (
<CommentCellBody
// `key` prevents an issue with duplicate pins displayed in the UI
key={`${row}-${col}-${rowId}-${columnId}`}
rowId={rowId}
columnId={columnId}
value={value}
/>
);
}

const COMMENT_PIN_SIZE = 24;

const commentPinStyle = {
"--lb-comment-pin-padding": "3px",
width: COMMENT_PIN_SIZE,
height: COMMENT_PIN_SIZE,
cursor: "pointer",
marginTop: 3,
boxSizing: "border-box",
} as CSSProperties;

// Displays comment pins alongside cell contents
function CommentCellBody({
rowId,
columnId,
value,
}: {
rowId: string;
columnId: string;
value: unknown;
}) {
const { threads, openCell, setOpenCell } = useCellThread();
const [isComposerOpen, setIsComposerOpen] = useState(false);

// Get the current user's ID, set when authenticating Liveblocks
const currentUserId = useSelf((self) => self.id) ?? undefined;

// Each cell has a thread, find the thread for this cell
// Metadata is set when creating a thread
const thread = threads.find(
({ metadata }) => metadata.rowId === rowId && metadata.columnId === columnId
);

// When the thread matches the open cell, open it by default
const defaultOpen =
openCell !== null &&
openCell.rowId === rowId &&
openCell.columnId === columnId;

// Metadata for this thread
const metadata = { rowId, columnId };

return (
<div
className="comment-cell"
style={{
display: "flex",
alignItems: "center",
gap: 12,
}}
>
<span className="comment-cell-value">{String(value ?? "")}</span>

{!thread ? (
// If there's no thread, show a thread composer on hover
<div
className="comment-cell-trigger"
data-open={isComposerOpen || undefined}
>
<FloatingComposer
className="ht-theme-main"
// Set { rowId, columnId } metadata on new threads when created
metadata={metadata}
onComposerSubmit={() => setOpenCell(metadata)}
onOpenChange={setIsComposerOpen}
style={{ zIndex: 10 }}
>
<CommentPin
corner="top-left"
style={commentPinStyle}
// Resolves the image from the current user's ID
userId={currentUserId}
>
{!isComposerOpen ? (
<Icon.Plus style={{ width: 14, height: 14 }} />
) : null}
</CommentPin>
</FloatingComposer>
</div>
) : (
// If a thread has been created already, show it with an avatar pin
<FloatingThread
className="ht-theme-main"
thread={thread}
defaultOpen={defaultOpen}
onOpenChange={(isOpen) => {
if (!isOpen && defaultOpen) {
setOpenCell(null);
}
}}
onComposerSubmit={() => setOpenCell(metadata)}
style={{ zIndex: 10 }}
autoFocus
>
<CommentPin
corner="top-left"
style={commentPinStyle}
// Resolves the image from the writer of thefirst comment in the thread
userId={thread.comments[0]?.userId}
/>
</FloatingThread>
)}
</div>
);
}
18 changes: 18 additions & 0 deletions examples/nextjs-comments-handsontable/app/Loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function Loading() {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<img
src="https://liveblocks.io/loading.svg"
alt="Loading"
style={{ width: 64, height: 64, opacity: 0.2 }}
/>
</div>
);
}
35 changes: 35 additions & 0 deletions examples/nextjs-comments-handsontable/app/Providers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use client";

import { LiveblocksProvider } from "@liveblocks/react";
import { PropsWithChildren } from "react";

export function Providers({ children }: PropsWithChildren) {
return (
<LiveblocksProvider
authEndpoint="/api/liveblocks-auth"
resolveUsers={async ({ userIds }) => {
const searchParams = new URLSearchParams(
userIds.map((userId) => ["userIds", userId])
);
const response = await fetch(`/api/users?${searchParams}`);
if (!response.ok) {
throw new Error("Problem resolving users");
}
const users = await response.json();
return users;
}}
resolveMentionSuggestions={async ({ text }) => {
const response = await fetch(
`/api/users/search?text=${encodeURIComponent(text)}`
);
if (!response.ok) {
throw new Error("Problem resolving mention suggestions");
}
const userIds = await response.json();
return userIds;
}}
>
{children}
</LiveblocksProvider>
);
}
31 changes: 31 additions & 0 deletions examples/nextjs-comments-handsontable/app/Room.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use client";

import { ReactNode, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { RoomProvider, ClientSideSuspense } from "@liveblocks/react/suspense";
import { Loading } from "./Loading";

export function Room({ children }: { children: ReactNode }) {
const roomId = useExampleRoomId(
"liveblocks:examples:nextjs-comments-handsontable"
);

return (
<RoomProvider id={roomId}>
<ClientSideSuspense fallback={<Loading />}>{children}</ClientSideSuspense>
</RoomProvider>
);
}

/**
* This function is used when deploying an example on liveblocks.io.
* You can ignore it completely if you run the example locally.
*/
function useExampleRoomId(roomId: string) {
const params = useSearchParams();
const exampleId = params?.get("exampleId");

return useMemo(() => {
return exampleId ? `${roomId}-${exampleId}` : roomId;
}, [roomId, exampleId]);
}
Loading
Loading