neiam
/waxx
waxx
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
card background images
f28f3dd · gmorell · 2026-06-23 17:33
Message
{commit_body(@commit)}
Files changed
modified
android/app/src/main/java/org/neiam/waxx/app/data/Models.kt
+11
−0
| 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 | 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 | 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 | 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 | 407 | } |
| 381 | 408 | } |
| 382 | 409 | } |
| 410 | + } | |
| 383 | 411 | } |
| 384 | 412 | |
| 385 | 413 | if (confirmDelete) { |
| 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 | 32 | CardLabel, |
| 33 | 33 | CardFieldValue, |
| 34 | 34 | CardNote, |
| 35 | + CardBackground, | |
| 35 | 36 | CardTemplate |
| 36 | 37 | } |
| 37 | 38 |
| 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 | 12 | CardLabel, |
| 13 | 13 | CardFieldValue, |
| 14 | 14 | CardNote, |
| 15 | + CardBackground, | |
| 15 | 16 | Subboard |
| 16 | 17 | } |
| 17 | 18 |
| 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
| 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