neiam
/waxx
waxx
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
show card backgrounds on kanban tiles
2a1eab5 · gmorell · 2026-06-23 19:18
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