card background images

f28f3dd · gmorell · 2026-06-23 17:33

38 files +410 -3
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/waxx/app/data/Models.kt
+11 −0
@@ -217,6 +217,17 @@ data class CardDetail(
217 217 val label_ids: List<String> = emptyList(),
218 218 val field_values: List<CardFieldValue> = emptyList(),
219 219 val notes: List<CardNote> = emptyList(),
220 + val background: CardBackground? = null,
221 +)
222 +
223 +/**
224 + * A pasted background image for a card. `data` is the base64-encoded image
225 + * bytes; only delivered on the single-card detail payload, never the list.
226 + */
227 +@Serializable
228 +data class CardBackground(
229 + val content_type: String,
230 + val data: String,
220 231 )
221 232
222 233 @Serializable
modified android/app/src/main/java/org/neiam/waxx/app/ui/CardSheet.kt
+40 −0
@@ -1,5 +1,8 @@
1 1 package org.neiam.waxx.app.ui
2 2
3 +import android.graphics.BitmapFactory
4 +import android.util.Base64
5 +import androidx.compose.foundation.Image
3 6 import androidx.compose.foundation.background
4 7 import androidx.compose.foundation.clickable
5 8 import androidx.compose.foundation.layout.Arrangement
@@ -46,10 +49,14 @@ import androidx.compose.runtime.rememberCoroutineScope
46 49 import androidx.compose.runtime.setValue
47 50 import androidx.compose.ui.Modifier
48 51 import androidx.compose.ui.graphics.Color
52 +import androidx.compose.ui.graphics.ImageBitmap
53 +import androidx.compose.ui.graphics.asImageBitmap
54 +import androidx.compose.ui.layout.ContentScale
49 55 import androidx.compose.ui.unit.dp
50 56 import kotlinx.coroutines.launch
51 57 import org.neiam.waxx.app.data.AddAssigneeBody
52 58 import org.neiam.waxx.app.data.BoardMembership
59 +import org.neiam.waxx.app.data.CardBackground
53 60 import org.neiam.waxx.app.data.CardDetail
54 61 import org.neiam.waxx.app.data.CardSummary
55 62 import org.neiam.waxx.app.data.CreateNoteBody
@@ -124,7 +131,27 @@ fun CardSheet(
124 131 val labelDefs = workflow?.labels.orEmpty()
125 132 val subboardDefs = workflow?.subboards.orEmpty().sortedBy { it.position }
126 133
134 + // A pasted background image (set from the web) decoded once per change.
135 + // Painted behind the sheet content with a translucent `surface` overlay so
136 + // it blends into the card and the text stays legible — mirroring the web.
137 + val background = detail?.background
138 + val backgroundBitmap = remember(background?.data) { decodeBackground(background) }
139 +
127 140 ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
141 + Box(modifier = Modifier.fillMaxWidth()) {
142 + if (backgroundBitmap != null) {
143 + Image(
144 + bitmap = backgroundBitmap,
145 + contentDescription = null,
146 + contentScale = ContentScale.Crop,
147 + modifier = Modifier.matchParentSize(),
148 + )
149 + Box(
150 + modifier = Modifier
151 + .matchParentSize()
152 + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.72f)),
153 + )
154 + }
128 155 LazyColumn(
129 156 modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
130 157 verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -380,6 +407,7 @@ fun CardSheet(
380 407 }
381 408 }
382 409 }
410 + }
383 411 }
384 412
385 413 if (confirmDelete) {
@@ -403,6 +431,18 @@ fun CardSheet(
403 431 }
404 432 }
405 433
434 +// Decodes a card's base64 background payload into an ImageBitmap, or null if
435 +// it's absent or unreadable (a bad payload just means no background, not a crash).
436 +private fun decodeBackground(background: CardBackground?): ImageBitmap? {
437 + val data = background?.data ?: return null
438 + return try {
439 + val bytes = Base64.decode(data, Base64.DEFAULT)
440 + BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
441 + } catch (_: Exception) {
442 + null
443 + }
444 +}
445 +
406 446 @Composable
407 447 private fun SectionHeader(text: String) {
408 448 Text(
modified lib/waxx/kanban.ex
+68 −1
@@ -32,6 +32,7 @@ defmodule Waxx.Kanban do
32 32 CardLabel,
33 33 CardFieldValue,
34 34 CardNote,
35 + CardBackground,
35 36 CardTemplate
36 37 }
37 38
@@ -934,13 +935,79 @@ defmodule Waxx.Kanban do
934 935 :field_values,
935 936 :notes,
936 937 :created_by,
937 − :board_stage
938 + :board_stage,
939 + :background
938 940 ])
939 941 end
940 942 end
941 943
942 944 def change_card(%Card{} = card, attrs \\ %{}), do: Card.changeset(card, attrs)
943 945
946 + @doc """
947 + Sets (or replaces) a card's pasted background image from a `data:` URL
948 + like `data:image/png;base64,iVBORw0KGgo...`. Decodes the payload, enforces
949 + the allowed content types and size cap, and upserts the single per-card
950 + row. Returns `{:ok, background}` or `{:error, reason}`.
951 + """
952 + def set_card_background(%Card{} = card, data_url, opts \\ []) when is_binary(data_url) do
953 + with {:ok, content_type, bytes} <- decode_data_url(data_url) do
954 + result =
955 + %CardBackground{}
956 + |> CardBackground.changeset(%{
957 + card_id: card.id,
958 + content_type: content_type,
959 + image_data: bytes
960 + })
961 + |> Repo.insert(
962 + on_conflict: {:replace, [:content_type, :image_data, :updated_at]},
963 + conflict_target: :card_id
964 + )
965 +
966 + with {:ok, _bg} <- result do
967 + log(card.board_id, opts[:actor], "card_updated",
968 + card_id: card.id,
969 + meta: %{changes: ["background"], title: card.title}
970 + )
971 +
972 + # Repaints the card for every viewer who has it open — `:cards_changed`
973 + # re-fetches the expanded card (and its newly-set background).
974 + broadcast_cards_changed(card.board_id)
975 + end
976 +
977 + result
978 + end
979 + end
980 +
981 + @doc "Removes a card's background image, if any. Always returns `:ok`."
982 + def clear_card_background(%Card{} = card, opts \\ []) do
983 + {count, _} = Repo.delete_all(from(b in CardBackground, where: b.card_id == ^card.id))
984 +
985 + if count > 0 do
986 + log(card.board_id, opts[:actor], "card_updated",
987 + card_id: card.id,
988 + meta: %{changes: ["background"], title: card.title}
989 + )
990 +
991 + broadcast_cards_changed(card.board_id)
992 + end
993 +
994 + :ok
995 + end
996 +
997 + # Splits a `data:<content-type>;base64,<payload>` URL into its content type
998 + # and decoded bytes. Anything malformed is rejected as `:invalid_image`.
999 + defp decode_data_url("data:" <> rest) do
1000 + with [meta, b64] <- String.split(rest, ",", parts: 2),
1001 + [content_type | _] <- String.split(meta, ";"),
1002 + {:ok, bytes} <- Base.decode64(b64) do
1003 + {:ok, content_type, bytes}
1004 + else
1005 + _ -> {:error, :invalid_image}
1006 + end
1007 + end
1008 +
1009 + defp decode_data_url(_), do: {:error, :invalid_image}
1010 +
944 1011 def create_card(%Board{} = board, %User{id: user_id} = user, attrs) do
945 1012 # Default to the first stage if none supplied. Reject stages that don't
946 1013 # belong to this board.
modified lib/waxx/kanban/card.ex
+3 −0
@@ -12,6 +12,7 @@ defmodule Waxx.Kanban.Card do
12 12 CardLabel,
13 13 CardFieldValue,
14 14 CardNote,
15 + CardBackground,
15 16 Subboard
16 17 }
17 18
@@ -39,6 +40,8 @@ defmodule Waxx.Kanban.Card do
39 40 has_many :field_values, CardFieldValue
40 41 has_many :notes, CardNote, preload_order: [asc: :position, asc: :inserted_at]
41 42
43 + has_one :background, CardBackground
44 +
42 45 timestamps(type: :utc_datetime)
43 46 end
44 47
added lib/waxx/kanban/card_background.ex
+50 −0
@@ -0,0 +1,50 @@
1 +defmodule Waxx.Kanban.CardBackground do
2 + @moduledoc """
3 + A background image a user pasted onto a card. Stored as raw bytes plus a
4 + content type, one row per card. The board view renders cards from
5 + `Kanban.list_cards/1`, which does not load this association — the bytes
6 + only travel to the client when a single card is expanded, where they're
7 + inlined as a `data:` URL behind a translucent `base-100` overlay so the
8 + image blends into the card surface.
9 + """
10 + use Waxx.Schema
11 + import Ecto.Changeset
12 +
13 + alias Waxx.Kanban.Card
14 +
15 + # Pasted screenshots are typically well under this; the cap keeps a stray
16 + # huge paste from being shipped over the LiveView channel and stored.
17 + @max_bytes 5_000_000
18 + @content_types ~w(image/png image/jpeg image/gif image/webp)
19 +
20 + schema "card_backgrounds" do
21 + field :content_type, :string
22 + field :image_data, :binary
23 +
24 + belongs_to :card, Card
25 +
26 + timestamps(type: :utc_datetime)
27 + end
28 +
29 + def max_bytes, do: @max_bytes
30 + def content_types, do: @content_types
31 +
32 + def changeset(bg, attrs) do
33 + bg
34 + |> cast(attrs, [:card_id, :content_type, :image_data])
35 + |> validate_required([:card_id, :content_type, :image_data])
36 + |> validate_inclusion(:content_type, @content_types)
37 + |> validate_byte_size()
38 + |> unique_constraint(:card_id)
39 + end
40 +
41 + defp validate_byte_size(changeset) do
42 + validate_change(changeset, :image_data, fn :image_data, data ->
43 + if byte_size(data) > @max_bytes do
44 + [image_data: "is too large (max #{div(@max_bytes, 1_000_000)} MB)"]
45 + else
46 + []
47 + end
48 + end)
49 + end
50 +end
modified lib/waxx_web/api/v1/board_json.ex
+16 −1

Click to load diff…

modified lib/waxx_web/api/v1/card_controller.ex
+1 −1

Click to load diff…

modified lib/waxx_web/live/board_live/show.ex
+127 −0

Click to load diff…

added priv/repo/migrations/20260620000000_add_card_backgrounds.exs
+19 −0

Click to load diff…

modified test/waxx/kanban_test.exs
+51 −0

Click to load diff…

modified test/waxx_web/api/v1/card_controller_test.exs
+24 −0

Click to load diff…

Parents: 555ace3