Skip to content

Repository files navigation

PhoenixKitBoards

Collaborative infinite-canvas boards for PhoenixKit.

Admins open Boards in the sidebar, create a board, and open it — an infinite Fresco canvas with the Etcher drawing layer (shapes, text, images). Multiple people on the same board see each other's edits, cursors, and presence in real time. Each board is one row in phoenix_kit_boards.

Installation

# host app mix.exs
{:phoenix_kit_boards, "~> 0.6"}
mix deps.get
mix phoenix_kit.update   # creates the phoenix_kit_boards table

Enable Boards on the admin Modules page. That's it — the sidebar tab, routes, permission, and JS all wire up automatically (PhoenixKit auto-discovers the module).

What you get

  • Boards list at /admin/boards — create / open / delete (all boards, whoever they belong to).
  • A board at /admin/boards/:id — the collaborative canvas.
  • Real-time collaboration — shapes/text/images sync across everyone on the board; a presence roster and live cursors show who's there.
  • Pasted images go to storage — not into the board document. A pasted or dropped image is uploaded through PhoenixKit's Storage module and the shape keeps only its URL.
  • Pasted links become preview cards — the server fetches the page's OpenGraph tags (under an SSRF guard, a size cap and a time budget), draws a card, and stores it like any other board image. A link that can't be previewed simply stays on the canvas as text.
  • DB-backed — the Fresco.Canvas document is stored in the data jsonb column.
  • Boards that belong somewhere — a board can belong to a place (a project, a contact, a page…), be private to its creator or shared with everyone who can see that place, and keep its files in a media folder of its own. See "Boards in your own pages" below.
  • A drop-in widget — PhoenixKitBoards.Web.Components.boards_panel/1 is a grid of a place's quick boards with a + tile: start from nothing, a note or a picture.

Boards in your own pages

A widget for the quick boards of one place — put it in a section of any page, wherever people jot things down:

<PhoenixKitBoards.Web.Components.boards_panel
  id="project-notes"
  scope={{"project", @project.uuid}}
  label="Notes"
  current_user={@current_scope.user}
  return_to={~p"/projects/#{@project}"}
/>

It is a grid of tiles, one per board, and a last tile of the same size that makes a new one. With no boards yet that tile is all there is, centred:

  • the big + makes an empty canvas and opens it;
  • Text makes a canvas with a note already on it;
  • Image makes a canvas with a picture already on it — the phone's camera or any file, and the tile is a drop target too;
  • Paste — click into the widget and paste text (a note board) or images (one board with all of them, plus any text that came along). It listens only while the widget has focus and never while you are typing in one of its fields; the tile shows a hint while it is listening and a notice with Undo follows. paste={false} turns it off.

Text and image boards stay on the page and show up as tiles; the empty one opens, since there is nothing in it to see. A tile's menu renames, pins, makes private or shared, archives or deletes. The widget does not manage places or projects: you choose what place it is for (scope is any {type, id} pair of strings — a record, {"project", uuid}, or something that is not one, {"page", "/docs/setup"}) and put one wherever you want one. Every attribute is in the PhoenixKitBoards.Web.BoardsPanel moduledoc; the ones you will reach for:

attribute what it does
folder where the files of these boards go (below)
visibility_options / default_visibility who can see what people create here; one value hides the choice ([:private] = a personal notepad, [:shared] = a team one)
actions which ways to start a board to offer, from [:empty, :text, :image]
limit tiles shown before "Show all"
open :navigate or :new_tab
show_label false to keep label for the folder name without drawing it

Boards opened from the widget open at /dashboard/boards/:id (so people who are not admins can open them) and link back to return_to.

Who may see a place

The module cannot know what "can see project X" means in your app, so it asks:

config :phoenix_kit_boards, :authorize, {MyApp.Boards, :can?}

# MyApp.Boards
def can?(action, user, {"project", id}) when action in [:view, :create],
  do: MyApp.Projects.member?(id, user)

With no hook any signed-in user may view and create. A hook that raises, exits or answers anything but a boolean denies. The hook can narrow access, never widen it: private is always its creator's (users holding the boards admin permission may still open any board, as they always could on /admin/boards). The panel renders nothing for a place the user may not view. Boards with no place (scope={nil}) are the admin list's own, so a panel for them shows only to those admins.

Where a board's files go

Every upload on a board is filed in that board's own media folder, created on its first upload, so one folder holds exactly one board's files and nobody has to guess which picture belongs to which board. By default that folder is Boards / <place> / <board name>. To put them somewhere of your own — say each order has a folder, however deep, and each board needs a folder inside it:

<%!-- inside that folder (the board gets a folder of its own in it) --%>
<PhoenixKitBoards.Web.Components.boards_panel ... folder={{:under, @order.storage_folder_uuid}} />

<%!-- that chain of folders from the media root, found or created --%>
<PhoenixKitBoards.Web.Components.boards_panel ... folder={{:path, ["Orders", @order.number, "Boards"]}} />

<%!-- the media root itself --%>
<PhoenixKitBoards.Web.Components.boards_panel ... folder={:root} />

<%!-- or work it out when a board is made: fn {type, id}, actor_uuid -> {:ok, folder_uuid} end --%>
<PhoenixKitBoards.Web.Components.boards_panel ... folder={&MyApp.Orders.boards_folder/2} />

or for the whole app, with the same hook convention the other modules use:

config :phoenix_kit_boards,
  attachments_parent_folder: {MyApp.Media, :board_parent},   # fun(:board, actor_uuid, %{scope:, label:})
  attachments_folder_name: {MyApp.Media, :board_name}        # fun(board, actor_uuid)

What to know:

  • The parent is chosen when the board is made and remembered, so changing folder later affects new boards only. The board's own folder is made by its first upload (or by PhoenixKitBoards.MediaFolders.ensure_folder/2 if you want it to exist at once, to link to it).
  • The folder is named after the board and follows a rename — unless someone renamed the folder by hand, or your name hook named it, in which case it is left alone. Two boards with the same name beside each other get board-<uuid> for the second.
  • The folder may be in any storage library, not only Media: files are stored into the folder's library, and the board's folder appears in that library's browser. (Core's own ResourceFolders.ensure/4 stamps new folders with the Media library whatever their parent's is; boards makes such folders through Storage.create_folder/1 instead.) A managed library refuses new folders, so files there are stored but stay loose and a warning is logged.
  • A folder that is not a live folder, or a function that fails, falls back to the default layout — never the media root, where nobody would look.
  • Deleting a board moves its folder to the media trash; files are never deleted.

What a place can be

scope is a pair of a type and an id: strings, integers or atoms, so {"order", 1001}, {:project, project.id} and {"page", "/docs/setup"} all work. They are stored as strings, and that is what the :authorize hook receives ({"order", "1001"}). A place with a missing id ({"order", nil}) shows nothing — never the boards that belong nowhere. Nothing in your database refers to a board: deleting an order does not delete its boards or folder (find them by scope with PhoenixKitBoards.Boards.list_for_scope/3).

Staying current

The widget re-reads itself every few seconds (poll, in milliseconds; 0 turns it off), which needs nothing from your page. For instant updates, subscribe your LiveView to the place and forward the message:

# mount/3, when connected?
PhoenixKitBoards.Boards.subscribe_scope({"project", project.uuid})

def handle_info({:boards_scope_changed, _board_uuid}, socket) do
  PhoenixKitBoards.Web.BoardsPanel.refresh("project-notes")
  {:noreply, socket}
end

How it works

  • PhoenixKit core already ships and loads fresco.js + etcher.js (the media annotation feature), so <Fresco.canvas> + <Etcher.layer> work here with no host JS setup. This module's collaboration hooks are delivered by the board page itself, as LiveView runtime hooks, so they work whatever the host does about JS — no compiler entry, no script tag, no app.js import. js_sources/0 is still declared as a fast path for hosts running core's :phoenix_kit_js_sources compiler, and LiveView prefers it where it exists.
  • Editing re-emits the full etcher annotation list (etcher:annotations-changed); the LiveView takes what changed against the list it last held and applies that to the stored board under a row lock (Boards.apply_edit/3, so concurrent saves all land), then broadcasts the delta over PubSub once it is stored; peers apply it to their own list. Echo is broken server-side (an already-applied delta changes nothing).
  • Presence and cursors ride the same PubSub topic; cursors are sent in canvas coordinates so they track each viewer's pan/zoom.

Requirements

  • phoenix_kit ~> 2.0, fresco ~> 0.11, etcher ~> 0.11 (all resolved by the host — this module references their components and reuses their loaded JS).

  • PhoenixKit's Storage module, with at least one bucket enabled, if you want pasted images to be uploaded rather than embedded. Without it every upload fails, Etcher falls back to embedding the bytes in the shape, and the max_frame_size note below becomes load-bearing again.

  • A rasterizer is not required. Link preview cards are emitted as SVG and rasterised by the browser, so no :resvg NIF or resvg/rsvg-convert/ magick binary is needed.

  • Raise the LiveView socket's max_frame_size in the host endpoint.

    socket "/live", Phoenix.LiveView.Socket,
      websocket: [connect_info: [session: @session_options], max_frame_size: 64_000_000],
      longpoll: [connect_info: [session: @session_options]]

    Every edit re-emits the whole annotation list. Pasted images are uploaded to storage and travel as URLs, which is what keeps that list small — but Etcher falls back to embedding an image as a base64 data URL whenever an upload fails (no bucket configured, storage unreachable, the board closed mid-transfer), and one embedded screenshot is easily a few MB. A board carrying a couple of those pushes routine edits — moving a shape, typing a label — past the 8 MB default. The socket then closes with 1009 (Message Too Big) and reconnects, so the edit never reaches the server and is lost, with nothing to show for it but a flicker of the page's loading bar.

    So this is headroom for the fallback path, not the mechanism boards rely on. It raises the ceiling; it does not remove it.

Optional: the ephemeral socket

Cursors and in-flight drags are high-rate and worthless a moment later, and through a LiveView every position pays for a render and a diff — queued behind that process's real work: saving edits, uploads, link previews. They can go over a channel of their own instead. Sockets are declared on the endpoint, and that belongs to the host, so this is one line in endpoint.ex:

socket "/phoenix_kit/board", PhoenixKitBoards.Web.BoardSocket, websocket: true

Skipping it loses nothing that worked before: cursors fall back to the LiveView relay, which is where they used to go, and live drags simply don't appear. The client also falls back while the channel is joining and if it fails, so a socket that dies mid-session degrades rather than dropping cursors silently.

Joining is governed by a short-lived signed token naming the board and the peer, minted by the LiveView that rendered the page. The channel re-derives both from it, so a client cannot join a board it was never shown, or present as somebody else. Mounted somewhere other than the default path? Say so, and the client is told rather than left to guess:

config :phoenix_kit_boards, board_socket_path: "/somewhere/else"

License

MIT.

Releases

Contributors

Languages