add SettingsField struct and repeater field type for block settings
All checks were successful
deploy / deploy (push) Successful in 1m23s

Introduces typed settings schema with SettingsField struct, replaces
the read-only JSON textarea with a full repeater UI for info_card items.
Supports add, remove, reorder and inline editing of repeater items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-27 00:54:13 +00:00
parent 3f97742c0b
commit 6fbd654d57
5 changed files with 470 additions and 52 deletions

View File

@@ -2,7 +2,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view
alias Berrypod.Pages
alias Berrypod.Pages.{BlockTypes, Defaults}
alias Berrypod.Pages.{BlockTypes, Defaults, SettingsField}
@impl true
def mount(%{"slug" => slug}, _session, socket) do
@@ -162,6 +162,88 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
end
end
def handle_event("repeater_add", %{"block-id" => block_id, "field" => field_key}, socket) do
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
if block do
schema = BlockTypes.get(block["type"])
field = Enum.find(schema.settings_schema, &(&1.key == field_key))
empty_item = Map.new(field.item_schema, fn f -> {f.key, f.default} end)
items = (block["settings"] || %{})[field_key] || []
new_items = items ++ [empty_item]
new_blocks = update_block_field(socket.assigns.blocks, block_id, field_key, new_items)
{:noreply,
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)
|> assign(:live_region_message, "Item added")}
else
{:noreply, socket}
end
end
def handle_event(
"repeater_remove",
%{"block-id" => block_id, "field" => field_key, "index" => index_str},
socket
) do
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
if block do
index = String.to_integer(index_str)
items = (block["settings"] || %{})[field_key] || []
new_items = List.delete_at(items, index)
new_blocks = update_block_field(socket.assigns.blocks, block_id, field_key, new_items)
{:noreply,
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)
|> assign(:live_region_message, "Item removed")}
else
{:noreply, socket}
end
end
def handle_event(
"repeater_move",
%{"block-id" => block_id, "field" => field_key, "index" => index_str, "dir" => dir},
socket
) do
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
if block do
index = String.to_integer(index_str)
items = (block["settings"] || %{})[field_key] || []
target = if dir == "up", do: index - 1, else: index + 1
if target >= 0 and target < length(items) do
item = Enum.at(items, index)
new_items =
items
|> List.delete_at(index)
|> List.insert_at(target, item)
new_blocks = update_block_field(socket.assigns.blocks, block_id, field_key, new_items)
{:noreply,
socket
|> assign(:blocks, new_blocks)
|> assign(:dirty, true)
|> assign(:live_region_message, "Item moved #{dir}")}
else
{:noreply, socket}
end
else
{:noreply, socket}
end
end
def handle_event("show_picker", _params, socket) do
{:noreply,
socket
@@ -470,34 +552,114 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
"""
end
defp block_field(%{field: %{type: :json}} = assigns) do
json_str =
case assigns.value do
val when is_list(val) or is_map(val) -> Jason.encode!(val, pretty: true)
val when is_binary(val) -> val
_ -> "[]"
end
defp block_field(%{field: %{type: :repeater}} = assigns) do
items = if is_list(assigns.value), do: assigns.value, else: []
item_count = length(items)
assigns = assign(assigns, :json_str, json_str)
assigns =
assigns
|> assign(:items, items)
|> assign(:item_count, item_count)
~H"""
<div class="admin-fieldset">
<label>
<span class="admin-label">{@field.label}</span>
<textarea
name={"block_settings[#{@field.key}]"}
id={"block-#{@block_id}-#{@field.key}"}
class="admin-textarea block-settings-json"
rows="4"
readonly
aria-readonly="true"
>{@json_str}</textarea>
<p class="block-settings-hint">JSON data — editing coming soon</p>
</label>
<div class="repeater-field">
<span class="admin-label">{@field.label}</span>
<ol class="repeater-items" aria-label={@field.label}>
<li :for={{item, idx} <- Enum.with_index(@items)} class="repeater-item">
<fieldset>
<legend class="sr-only">
Item {idx + 1}: {item["label"] || item[:label] || "New item"}
</legend>
<div class="repeater-item-fields">
<.repeater_item_field
:for={sub_field <- @field.item_schema}
sub_field={sub_field}
item={item}
field_key={@field.key}
block_id={@block_id}
index={idx}
/>
</div>
<div class="repeater-item-controls">
<button
type="button"
phx-click="repeater_move"
phx-value-block-id={@block_id}
phx-value-field={@field.key}
phx-value-index={idx}
phx-value-dir="up"
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-xs"
aria-label={"Move item #{idx + 1} up"}
disabled={idx == 0}
>
<.icon name="hero-chevron-up-mini" class="size-3.5" />
</button>
<button
type="button"
phx-click="repeater_move"
phx-value-block-id={@block_id}
phx-value-field={@field.key}
phx-value-index={idx}
phx-value-dir="down"
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-xs"
aria-label={"Move item #{idx + 1} down"}
disabled={idx == @item_count - 1}
>
<.icon name="hero-chevron-down-mini" class="size-3.5" />
</button>
<button
type="button"
phx-click="repeater_remove"
phx-value-block-id={@block_id}
phx-value-field={@field.key}
phx-value-index={idx}
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-xs repeater-remove-btn"
aria-label={"Remove item #{idx + 1}"}
>
<.icon name="hero-x-mark-mini" class="size-3.5" />
</button>
</div>
</fieldset>
</li>
</ol>
<button
type="button"
phx-click="repeater_add"
phx-value-block-id={@block_id}
phx-value-field={@field.key}
class="admin-btn admin-btn-outline admin-btn-xs repeater-add-btn"
>
<.icon name="hero-plus-mini" class="size-3.5" /> Add item
</button>
</div>
"""
end
defp repeater_item_field(assigns) do
value = assigns.item[assigns.sub_field.key] || ""
assigns = assign(assigns, :value, value)
~H"""
<label class="repeater-sub-field">
<span class="sr-only">{@sub_field.label}</span>
<input
type="text"
name={"block_settings[#{@field_key}][#{@index}][#{@sub_field.key}]"}
id={"block-#{@block_id}-#{@field_key}-#{@index}-#{@sub_field.key}"}
value={@value}
placeholder={@sub_field.label}
class="admin-input admin-input-sm"
phx-debounce="300"
/>
</label>
"""
end
defp block_field(assigns) do
~H"""
<div class="admin-fieldset">
@@ -589,12 +751,34 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
%{type: :number, default: default} ->
{key, parse_number(value, default)}
%{type: :repeater} ->
{key, indexed_map_to_list(value)}
_ ->
{key, value}
end
end)
end
defp indexed_map_to_list(map) when is_map(map) do
map
|> Enum.sort_by(fn {k, _v} -> String.to_integer(k) end)
|> Enum.map(fn {_k, v} -> v end)
end
defp indexed_map_to_list(value), do: value
defp update_block_field(blocks, block_id, field_key, new_value) do
Enum.map(blocks, fn b ->
if b["id"] == block_id do
settings = Map.put(b["settings"] || %{}, field_key, new_value)
Map.put(b, "settings", settings)
else
b
end
end)
end
defp parse_number(value, default) when is_binary(value) do
case Integer.parse(value) do
{n, ""} -> n