show card backgrounds on kanban tiles

2a1eab5 · gmorell · 2026-06-23 19:18

16 files +169 -2
Message
{commit_body(@commit)}

Files changed

modified lib/waxx/kanban.ex
+40 −0
@@ -1010,6 +1010,46 @@ defmodule Waxx.Kanban do
1010 1010 :ok
1011 1011 end
1012 1012
1013 + @doc """
1014 + Map of `card_id => version` for the board's cards that have a background
1015 + image, where `version` is the background's `updated_at` as a unix
1016 + timestamp. Used to flag which tiles get a background and to cache-bust the
1017 + image URL when it changes. Cheap — selects ids/timestamps only, no bytes.
1018 + """
1019 + def background_versions(%Board{id: board_id}) do
1020 + from(b in CardBackground,
1021 + join: c in Card,
1022 + on: c.id == b.card_id,
1023 + where: c.board_id == ^board_id,
1024 + select: {b.card_id, b.updated_at}
1025 + )
1026 + |> Repo.all()
1027 + |> Map.new(fn {id, updated_at} -> {id, DateTime.to_unix(updated_at)} end)
1028 + end
1029 +
1030 + @doc """
1031 + Loads a card's background bytes for serving, but only if `user` can see the
1032 + card's board. Returns `{:ok, %CardBackground{}}` or `:error`. The bytes are
1033 + fetched here (not on the board list) so they travel only when actually served.
1034 + """
1035 + def fetch_card_background_for_user(card_id, %User{} = user) do
1036 + query =
1037 + from(b in CardBackground,
1038 + join: c in Card,
1039 + on: c.id == b.card_id,
1040 + where: b.card_id == ^card_id,
1041 + select: {c.board_id, b}
1042 + )
1043 +
1044 + case Repo.one(query) do
1045 + {board_id, %CardBackground{} = bg} ->
1046 + if role_for(board_id, user), do: {:ok, bg}, else: :error
1047 +
1048 + _ ->
1049 + :error
1050 + end
1051 + end
1052 +
1013 1053 # Splits a `data:<content-type>;base64,<payload>` URL into its content type
1014 1054 # and decoded bytes. Anything malformed is rejected as `:invalid_image`.
1015 1055 defp decode_data_url("data:" <> rest) do
added lib/waxx_web/controllers/card_background_controller.ex
+30 −0
@@ -0,0 +1,30 @@
1 +defmodule WaxxWeb.CardBackgroundController do
2 + @moduledoc """
3 + Serves a card's pasted background image so the kanban board tiles can
4 + reference it as a plain `background-image: url(...)` rather than inlining
5 + the bytes into the board payload. Access is gated on the caller being able
6 + to see the card's board.
7 + """
8 + use WaxxWeb, :controller
9 +
10 + alias Waxx.Kanban
11 +
12 + def show(conn, %{"id" => card_id}) do
13 + user = conn.assigns.current_scope.user
14 +
15 + case Kanban.fetch_card_background_for_user(card_id, user) do
16 + {:ok, bg} ->
17 + conn
18 + # The URL is cache-busted by a `?v=<updated_at>` param, so the bytes
19 + # for a given version are immutable and safe to cache privately.
20 + |> put_resp_header("cache-control", "private, max-age=31536000, immutable")
21 + |> put_resp_content_type(bg.content_type, nil)
22 + |> send_resp(200, bg.image_data)
23 +
24 + :error ->
25 + conn
26 + |> put_status(:not_found)
27 + |> text("Not found")
28 + end
29 + end
30 +end
modified lib/waxx_web/live/board_live/show.ex
+29 −2
@@ -28,6 +28,7 @@ defmodule WaxxWeb.BoardLive.Show do
28 28 |> assign(:board, board)
29 29 |> assign(:role, role)
30 30 |> assign(:cards, cards)
31 + |> assign(:background_versions, Kanban.background_versions(board))
31 32 |> assign(:transitions_json, transitions_json(board))
32 33 |> assign(:show_new_card_for, nil)
33 34 |> assign(:expanded_card, nil)
@@ -44,7 +45,12 @@ defmodule WaxxWeb.BoardLive.Show do
44 45 def handle_info({:cards_changed, _board_id}, socket) do
45 46 cards = Kanban.list_cards(socket.assigns.board)
46 47 expanded = refresh_expanded(socket)
47 − {:noreply, socket |> assign(:cards, cards) |> assign(:expanded_card, expanded)}
48 +
49 + {:noreply,
50 + socket
51 + |> assign(:cards, cards)
52 + |> assign(:expanded_card, expanded)
53 + |> assign(:background_versions, Kanban.background_versions(socket.assigns.board))}
48 54 end
49 55
50 56 # Stage/transition graph changed — typically from a template edit
@@ -62,7 +68,8 @@ defmodule WaxxWeb.BoardLive.Show do
62 68 |> assign(:board, board)
63 69 |> assign(:transitions_json, transitions_json(board))
64 70 |> assign(:cards, cards)
65 − |> assign(:expanded_card, expanded)}
71 + |> assign(:expanded_card, expanded)
72 + |> assign(:background_versions, Kanban.background_versions(board))}
66 73 end
67 74
68 75 # Pulls a fresh copy of the open card from the DB — but only when the
@@ -1095,6 +1102,7 @@ defmodule WaxxWeb.BoardLive.Show do
1095 1102 data-source-stage-id={@stage.id}
1096 1103 data-source-subboard-id={@subboard_id}
1097 1104 class="border border-base-300 rounded-box p-2 bg-base-100 cursor-pointer hover:bg-base-200"
1105 + style={card_tile_bg_style(card.id, @background_versions)}
1098 1106 phx-click="expand_card"
1099 1107 phx-value-id={card.id}
1100 1108 >
@@ -1643,6 +1651,25 @@ defmodule WaxxWeb.BoardLive.Show do
1643 1651
1644 1652 defp card_bg_style(_), do: nil
1645 1653
1654 + # Board-tile background: same blended look as the modal, but the image is
1655 + # referenced by URL (served lazily by CardBackgroundController) instead of
1656 + # inlined — the board payload never carries image bytes. `@background_versions`
1657 + # says which cards have one and supplies the cache-busting version. The
1658 + # overlay is a touch stronger than the modal's since tiles are small and dense.
1659 + defp card_tile_bg_style(card_id, versions) do
1660 + case versions do
1661 + %{^card_id => ver} ->
1662 + url = ~p"/cards/#{card_id}/background?#{%{v: ver}}"
1663 + overlay = "color-mix(in srgb, var(--color-base-100) 78%, transparent)"
1664 +
1665 + "background-image: linear-gradient(#{overlay}, #{overlay}), url('#{url}'); " <>
1666 + "background-size: cover; background-position: center;"
1667 +
1668 + _ ->
1669 + nil
1670 + end
1671 + end
1672 +
1646 1673 defp note_stage_name(%{board_stage_id: nil}, _board), do: "(no stage)"
1647 1674
1648 1675 defp note_stage_name(%{board_stage_id: stage_id}, board) do
modified lib/waxx_web/router.ex
+4 −0
@@ -72,6 +72,10 @@ defmodule WaxxWeb.Router do
72 72 end
73 73
74 74 post "/users/update-password", UserSessionController, :update_password
75 +
76 + # Card background image bytes, referenced by board tiles + the card
77 + # modal as `background-image: url(...)`. Auth inherited from the scope.
78 + get "/cards/:id/background", CardBackgroundController, :show
75 79 end
76 80
77 81 scope "/", WaxxWeb do
modified test/waxx/kanban_test.exs
+23 −0
@@ -836,6 +836,29 @@ defmodule Waxx.KanbanTest do
836 836 assert :ok = Kanban.clear_card_background(card)
837 837 assert Kanban.get_card(card.id).background == nil
838 838 end
839 +
840 + test "background_versions maps only cards that have a background", %{
841 + board: board,
842 + card: card,
843 + user: user
844 + } do
845 + other = Waxx.KanbanFixtures.card_fixture(board, user)
846 + assert {:ok, bg} = Kanban.set_card_background(card, @png_data_url)
847 +
848 + versions = Kanban.background_versions(board)
849 + assert versions[card.id] == DateTime.to_unix(bg.updated_at)
850 + refute Map.has_key?(versions, other.id)
851 + end
852 +
853 + test "fetch_card_background_for_user enforces board access", %{card: card, user: user} do
854 + assert {:ok, _} = Kanban.set_card_background(card, @png_data_url)
855 +
856 + assert {:ok, %Waxx.Kanban.CardBackground{}} =
857 + Kanban.fetch_card_background_for_user(card.id, user)
858 +
859 + stranger = AccountsFixtures.user_fixture()
860 + assert :error = Kanban.fetch_card_background_for_user(card.id, stranger)
861 + end
839 862 end
840 863
841 864 ## -- helpers --------------------------------------------------------------
added test/waxx_web/controllers/card_background_controller_test.exs
+43 −0
@@ -0,0 +1,43 @@
1 +defmodule WaxxWeb.CardBackgroundControllerTest do
2 + use WaxxWeb.ConnCase, async: true
3 +
4 + import Waxx.AccountsFixtures
5 + import Waxx.KanbanFixtures
6 +
7 + alias Waxx.Kanban
8 +
9 + # 1x1 transparent PNG.
10 + @png_data_url "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
11 +
12 + test "serves the image bytes with its content type to a board member", %{conn: conn} do
13 + user = confirmed_user_fixture()
14 + board = board_fixture(user)
15 + card = card_fixture(board, user)
16 + {:ok, bg} = Kanban.set_card_background(card, @png_data_url)
17 +
18 + conn = conn |> log_in_user(user) |> get(~p"/cards/#{card.id}/background")
19 +
20 + assert response(conn, 200) == bg.image_data
21 + assert get_resp_header(conn, "content-type") |> hd() =~ "image/png"
22 + end
23 +
24 + test "404 when the card has no background", %{conn: conn} do
25 + user = confirmed_user_fixture()
26 + board = board_fixture(user)
27 + card = card_fixture(board, user)
28 +
29 + conn = conn |> log_in_user(user) |> get(~p"/cards/#{card.id}/background")
30 + assert response(conn, 404)
31 + end
32 +
33 + test "404 when the caller can't see the card's board", %{conn: conn} do
34 + owner = confirmed_user_fixture()
35 + board = board_fixture(owner)
36 + card = card_fixture(board, owner)
37 + {:ok, _} = Kanban.set_card_background(card, @png_data_url)
38 +
39 + stranger = confirmed_user_fixture()
40 + conn = conn |> log_in_user(stranger) |> get(~p"/cards/#{card.id}/background")
41 + assert response(conn, 404)
42 + end
43 +end

Parents: f730ab0