SkillAgentSearch skills...

Gojo

A real-time multiplayer brainstorming web app built with Remix and Liveblocks.

Install / Use

/learn @tigerabrodi/Gojo
About this skill

Quality Score

0/100

Category

Design

Supported Platforms

Universal

README

Gojo

A real-time collaborative brainstorming web app built with Remix and Liveblocks.

Some notes:

  • Double click on board to create a card.
  • Click once to "focus" on card. Click again to begin entering text.
  • Focusing on a card brings it to the front.
  • When sharing, you can also copy link similar to Google Docs. Anyone with the link gets instant access.

https://github.com/tigerabrodi/gojo/assets/49603590/6bab85b4-e0cd-484b-ae87-c32e203b15cf

Get it running locally

  1. Clone or fork it.
  2. Run npm install
  3. Create a .env file in root. You're gonna need three environment variables: COOKIE_SECRET, LIVEBLOCKS_SECRET_KEY and DATABASE_URL.
  4. Run npm run dev

Environment variables

COOKIE_SECRET -> can be whatever you want, I'd recommend generating a random string. LIVEBLOCKS_SECRET_KEY -> setup account on Liveblocks and copy the secret private key from development environment. DATABASE_URL -> URL of a Postgres DB, I setup mine on Railway, it's super easy.

Features explained

<details> <summary>🍿 Add someone as Editor via Email</summary>

At the moment, you can only add someone as editor. Supporting other roles shouldn't be too hard, but I left it out for now.

To make this work, we keep track of the roles for every board.

model BoardRole {
  id       String   @id @default(uuid())
  role     String // owner, editor
  board    Board    @relation(fields: [boardId], references: [id], onDelete: Cascade)
  boardId  String
  user     User     @relation(fields: [userId], references: [id])
  userId   String
  addedAt DateTime @default(now())

  @@unique([boardId, userId]) // Ensure one role per user per board
}
</details> <details> <summary>🍿 zIndex management</summary>

When focusing on a card, we bring it to the front. The order of zIndex is kept via zIndexOrderListWithCardIds in the liveblocks storage.

In the liveblocks storage, we have an array of the cardIds zIndexOrderListWithCardIds. The last card has the highest zIndex in this list.

We get the zIndex for every card by simply calling indexOf using the card's id.

Liveblocks storage type code:

type Storage = {
  cards: LiveList<LiveObject<CardType>>
  zIndexOrderListWithCardIds: LiveList<string>
  boardName: string
}

Code inside Card component for bringing cards to the front:

const bringCardToFront = useMutation(({ storage }, cardId: string) => {
  const zIndexOrderListWithCardIds = storage.get('zIndexOrderListWithCardIds')
  const index = zIndexOrderListWithCardIds.findIndex((id) => id === cardId)

  if (index !== -1) {
    zIndexOrderListWithCardIds.delete(index)
    zIndexOrderListWithCardIds.push(cardId)
  }
}, [])

Side note

This is a simple way of managing zIndex. It's not the most efficient way, because e.g. adding something to beginning of the array is O(n) time complexity. Arrays are stored as a continuous block of memory, so adding something to the beginning means we have to shift everything else to the right, if there is no space available, we'd have to allocate a new block of memory and copy everything over.

If you were building something like Figma from scratch (no liveblocks) where milliseconds matter, you would probably want to consider a different approach.

</details> <details> <summary>🍿 Share access via link with secret Id</summary>

There is also the option to copy a share link on share dialog.

You can simply copy it and share it with a friend.

When they enter the link, they will instantly get access.

For every board, we create a secretId. The link appends this secretId as query parameter on the board's url. If it exists, we verify it's the correct one before creating a role for the new user. However, the user may already exist, so we're using upsert here in prisma.

Board model code:

model Board {
  id       String      @id @default(uuid())
  name     String
  secretId String      @default(uuid()) // secret Id
  roles    BoardRole[]
  lastOpenedAt DateTime?
  createdAt DateTime   @default(now())
  updatedAt DateTime   @updatedAt
}

Board route loader function, this runs on the server before client renders anything:

export async function loader({ params, request }: LoaderFunctionArgs) {
  const userId = await requireAuthCookie(request);
  const boardId = params.id;

  invariant(boardId, "No board ID provided");

  const currentUrl = new URL(request.url);
  const secretId = currentUrl.searchParams.get("secretId");

  if (secretId) {
    const isUserAllowedToEnterBoard =
      await checkUserAllowedToEnterBoardWithSecretId({
        boardId,
        secretId,
      });

    if (!isUserAllowedToEnterBoard) {
      throw redirectWithError("/boards", {
        message: "You are not allowed on this board.",
      });
    }

    await upsertUserBoardRole({
      userId,
      boardId,
    });
  }
// ...
</details> <details> <summary>🍿 Real-time cursors</summary>

This seems hard, and honestly, it is, but Liveblocks makes things simple to implement. There is a useOthers hook that gives us access to see the presence info of other users on the board in real time.

Code for mapping out the cursor component:

{
  others.map(({ connectionId, presence }) => {
    if (presence.cursor === null) {
      return null
    }

    return (
      <Cursor
        key={`cursor-${connectionId}`}
        color={getColorWithId(connectionId)}
        x={presence.cursor.x}
        y={presence.cursor.y}
        name={presence.name}
      />
    )
  })
}

We make sure to update the user's own presence when they're moving around the page:

      <main
        onDoubleClick={createNewCard}
        onPointerMove={(event) => {
          updateMyPresence({
            cursor: {
              x: Math.round(event.clientX),
              y: Math.round(event.clientY),
            },
          });
        }}
        onPointerLeave={() =>
          updateMyPresence({
            cursor: null,
          })
        }
      >
// ...

Get color with id function:

export function getColorWithId(id: number) {
  return COLORS[id % COLORS.length]
}

At scale where we expect many users on a single board, we'd need to make sure to have many more colors. Currently, COLORS contains 15 colors.

Cursor component:

import type { LinksFunction } from '@vercel/remix'
import cursorStyles from './Cursor.css'

type Props = {
  color: string
  name: string
  x: number
  y: number
}

export const cursorLinks: LinksFunction = () => [
  { rel: 'stylesheet', href: cursorStyles },
]

export function Cursor({ color, name, x, y }: Props) {
  return (
    <div
      className="cursor"
      style={{
        transform: `translateX(${x}px) translateY(${y}px)`,
        '--colors-cursor': color,
      }}
    >
      <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 15 22">
        <path
          fill={color}
          stroke="#162137"
          strokeWidth={1.5}
          d="M6.937 15.03h-.222l-.165.158L1 20.5v-19l13 13.53H6.937Z"
        />
      </svg>
      <span>{name}</span>
    </div>
  )
}
</details> <details> <summary>🍿 Moving and editing the card + showing who is doing what in real time</summary>

This was hard. I actually struggled with this for several hours, trying to figure out how to get it to work properly.

I had a flickering bug due to card's on blur function running whenever you click the second time to begin entering the text.

My main learning: onBlur runs whenever the focus leaves the component, EVEN if the focus leaves the component for an element inside the component. It was really hard to debug because it was like a deep assumption I've always had. 😅

We also have to keep track of whether the card was clicked already or not, if it wasn't clicked, we don't yet want to focus on the editable content inside the card.

Code when clicking on the card:

function onCardClick() {
  const isCardContentCurrentlyFocused =
    document.activeElement === cardContentRef.current

  if (isCardContentCurrentlyFocused) return

  if (!hasCardBeenClickedBefore) {
    setHasCardBeenClickedBefore(true)
    return
  }

  if (cardContentRef.current) {
    cardContentRef.current.focus()
    moveCursorToEnd(cardContentRef.current)
    setIsCardContentFocused(true)
    scrollToTheBottomOfCardContent()
    updateMyPresence({ isTyping: true })
  }
}

Now, this is where it gets funky.

When we focus we need to right away update the presence for other users, telling them we're focusing on the card. This gotta be done via onFocus and not onClick. Because onClick doesn't trigger till the finger leaves the mouse button.

Code for focusing on card:

function onCardFocus() {
  updateMyPresence({
    selectedCardId: card.id,
  })
}

When blurring the card, things also get interesting. There are several things we wanna do, and we ONLY want the blur logic to proceed if we're not about to edit the content.

Like I said before, blur happens when the focus leaves the element, even if the focus leaves an element for another one that's inside of it.

This is where I learned about relatedTarget, taken from MDN: "The MouseEvent.relatedTarget read-only property is the secondary target for the mouse event, if there is one."

This is similar to mouseleave event (referring to the MDN document), relatedTarget points to the element it enters.

Code for card blur:

function onCardBlur(event: FocusEvent<HTMLDivElement>) {
  // If we're focusing on card content, card's blur should not be triggered
  if (event.relatedTarget === cardContentRef.current) return

  cardContentRef.current?.blur()
  setIsCardContentFocused(false)
  setHasCardBeenClickedBefore(false)
  updateMyPresence({ isTyping: false, selectedCardId: null })
}

How do we know someon

View on GitHub
GitHub Stars62
CategoryDesign
Updated7d ago
Forks6

Languages

HTML

Security Score

85/100

Audited on Mar 25, 2026

No findings