add inline block settings editing to page editor
All checks were successful
deploy / deploy (push) Successful in 3m40s
All checks were successful
deploy / deploy (push) Successful in 3m40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
|> assign(:dirty, false)
|
||||
|> assign(:show_picker, false)
|
||||
|> assign(:picker_filter, "")
|
||||
|> assign(:expanded, MapSet.new())
|
||||
|> assign(:live_region_message, nil)}
|
||||
end
|
||||
|
||||
@@ -109,6 +110,58 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("toggle_expand", %{"id" => block_id}, socket) do
|
||||
expanded = socket.assigns.expanded
|
||||
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
|
||||
block_name = block_display_name(block)
|
||||
|
||||
{new_expanded, action} =
|
||||
if MapSet.member?(expanded, block_id) do
|
||||
{MapSet.delete(expanded, block_id), "collapsed"}
|
||||
else
|
||||
{MapSet.put(expanded, block_id), "expanded"}
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:expanded, new_expanded)
|
||||
|> assign(:live_region_message, "#{block_name} settings #{action}")}
|
||||
end
|
||||
|
||||
def handle_event("update_block_settings", params, socket) do
|
||||
block_id = params["block_id"]
|
||||
new_settings = params["block_settings"] || %{}
|
||||
|
||||
# Find the block and its schema to coerce types
|
||||
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
|
||||
|
||||
if block do
|
||||
schema =
|
||||
case BlockTypes.get(block["type"]) do
|
||||
%{settings_schema: s} -> s
|
||||
_ -> []
|
||||
end
|
||||
|
||||
coerced = coerce_settings(new_settings, schema)
|
||||
|
||||
new_blocks =
|
||||
Enum.map(socket.assigns.blocks, fn b ->
|
||||
if b["id"] == block_id do
|
||||
Map.put(b, "settings", Map.merge(b["settings"] || %{}, coerced))
|
||||
else
|
||||
b
|
||||
end
|
||||
end)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, new_blocks)
|
||||
|> assign(:dirty, true)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("show_picker", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
@@ -225,6 +278,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
block={block}
|
||||
idx={idx}
|
||||
total={length(@blocks)}
|
||||
expanded={@expanded}
|
||||
/>
|
||||
|
||||
<div :if={@blocks == []} class="block-list-empty">
|
||||
@@ -251,62 +305,213 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
|
||||
defp block_card(assigns) do
|
||||
block_type = BlockTypes.get(assigns.block["type"])
|
||||
assigns = assign(assigns, :block_type, block_type)
|
||||
has_settings = has_settings?(assigns.block)
|
||||
expanded = MapSet.member?(assigns.expanded, assigns.block["id"])
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:block_type, block_type)
|
||||
|> assign(:has_settings, has_settings)
|
||||
|> assign(:is_expanded, expanded)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
class="block-card"
|
||||
class={["block-card", @is_expanded && "block-card-expanded"]}
|
||||
role="listitem"
|
||||
aria-label={"#{@block_type && @block_type.name || @block["type"]}, position #{@idx + 1} of #{@total}"}
|
||||
id={"block-#{@block["id"]}"}
|
||||
>
|
||||
<span class="block-card-position">{@idx + 1}</span>
|
||||
<div class="block-card-header">
|
||||
<span class="block-card-position">{@idx + 1}</span>
|
||||
|
||||
<span class="block-card-icon">
|
||||
<.icon name={(@block_type && @block_type.icon) || "hero-puzzle-piece"} class="size-5" />
|
||||
</span>
|
||||
<span class="block-card-icon">
|
||||
<.icon name={(@block_type && @block_type.icon) || "hero-puzzle-piece"} class="size-5" />
|
||||
</span>
|
||||
|
||||
<span class="block-card-name">
|
||||
{(@block_type && @block_type.name) || @block["type"]}
|
||||
</span>
|
||||
<span class="block-card-name">
|
||||
{(@block_type && @block_type.name) || @block["type"]}
|
||||
</span>
|
||||
|
||||
<span class="block-card-controls">
|
||||
<button
|
||||
phx-click="move_up"
|
||||
phx-value-id={@block["id"]}
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm"
|
||||
aria-label={"Move #{@block_type && @block_type.name} up"}
|
||||
disabled={@idx == 0}
|
||||
<span class="block-card-controls">
|
||||
<button
|
||||
:if={@has_settings}
|
||||
phx-click="toggle_expand"
|
||||
phx-value-id={@block["id"]}
|
||||
class={[
|
||||
"admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm",
|
||||
@is_expanded && "block-edit-btn-active"
|
||||
]}
|
||||
aria-label={"Edit #{@block_type && @block_type.name} settings"}
|
||||
aria-expanded={to_string(@is_expanded)}
|
||||
aria-controls={"block-settings-#{@block["id"]}"}
|
||||
id={"block-edit-btn-#{@block["id"]}"}
|
||||
>
|
||||
<.icon name="hero-cog-6-tooth-mini" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="move_up"
|
||||
phx-value-id={@block["id"]}
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm"
|
||||
aria-label={"Move #{@block_type && @block_type.name} up"}
|
||||
disabled={@idx == 0}
|
||||
>
|
||||
<.icon name="hero-chevron-up-mini" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="move_down"
|
||||
phx-value-id={@block["id"]}
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm"
|
||||
aria-label={"Move #{@block_type && @block_type.name} down"}
|
||||
disabled={@idx == @total - 1}
|
||||
>
|
||||
<.icon name="hero-chevron-down-mini" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="duplicate_block"
|
||||
phx-value-id={@block["id"]}
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm"
|
||||
aria-label={"Duplicate #{@block_type && @block_type.name}"}
|
||||
>
|
||||
<.icon name="hero-document-duplicate-mini" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="remove_block"
|
||||
phx-value-id={@block["id"]}
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm block-remove-btn"
|
||||
aria-label={"Remove #{@block_type && @block_type.name}"}
|
||||
data-confirm={"Remove #{@block_type && @block_type.name}?"}
|
||||
>
|
||||
<.icon name="hero-trash-mini" class="size-4" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<.block_settings_form
|
||||
:if={@is_expanded}
|
||||
block={@block}
|
||||
schema={@block_type.settings_schema}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp block_settings_form(assigns) do
|
||||
settings = settings_with_defaults(assigns.block)
|
||||
assigns = assign(assigns, :settings, settings)
|
||||
|
||||
~H"""
|
||||
<div class="block-card-settings" id={"block-settings-#{@block["id"]}"}>
|
||||
<form phx-change="update_block_settings">
|
||||
<input type="hidden" name="block_id" value={@block["id"]} />
|
||||
|
||||
<div class="block-settings-fields">
|
||||
<.block_field
|
||||
:for={field <- @schema}
|
||||
field={field}
|
||||
value={@settings[field.key]}
|
||||
block_id={@block["id"]}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp block_field(%{field: %{type: :select}} = assigns) do
|
||||
~H"""
|
||||
<div class="admin-fieldset">
|
||||
<label>
|
||||
<span class="admin-label">{@field.label}</span>
|
||||
<select
|
||||
name={"block_settings[#{@field.key}]"}
|
||||
id={"block-#{@block_id}-#{@field.key}"}
|
||||
class="admin-select"
|
||||
phx-debounce="blur"
|
||||
>
|
||||
<.icon name="hero-chevron-up-mini" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="move_down"
|
||||
phx-value-id={@block["id"]}
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm"
|
||||
aria-label={"Move #{@block_type && @block_type.name} down"}
|
||||
disabled={@idx == @total - 1}
|
||||
>
|
||||
<.icon name="hero-chevron-down-mini" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="duplicate_block"
|
||||
phx-value-id={@block["id"]}
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm"
|
||||
aria-label={"Duplicate #{@block_type && @block_type.name}"}
|
||||
>
|
||||
<.icon name="hero-document-duplicate-mini" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="remove_block"
|
||||
phx-value-id={@block["id"]}
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm block-remove-btn"
|
||||
aria-label={"Remove #{@block_type && @block_type.name}"}
|
||||
data-confirm={"Remove #{@block_type && @block_type.name}?"}
|
||||
>
|
||||
<.icon name="hero-trash-mini" class="size-4" />
|
||||
</button>
|
||||
</span>
|
||||
{Phoenix.HTML.Form.options_for_select(@field.options, to_string(@value))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp block_field(%{field: %{type: :textarea}} = assigns) do
|
||||
~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"
|
||||
rows="3"
|
||||
phx-debounce="300"
|
||||
>{@value}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp block_field(%{field: %{type: :number}} = assigns) do
|
||||
~H"""
|
||||
<div class="admin-fieldset">
|
||||
<label>
|
||||
<span class="admin-label">{@field.label}</span>
|
||||
<input
|
||||
type="number"
|
||||
name={"block_settings[#{@field.key}]"}
|
||||
id={"block-#{@block_id}-#{@field.key}"}
|
||||
value={@value}
|
||||
class="admin-input"
|
||||
phx-debounce="blur"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
"""
|
||||
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
|
||||
|
||||
assigns = assign(assigns, :json_str, json_str)
|
||||
|
||||
~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>
|
||||
"""
|
||||
end
|
||||
|
||||
defp block_field(assigns) do
|
||||
~H"""
|
||||
<div class="admin-fieldset">
|
||||
<label>
|
||||
<span class="admin-label">{@field.label}</span>
|
||||
<input
|
||||
type="text"
|
||||
name={"block_settings[#{@field.key}]"}
|
||||
id={"block-#{@block_id}-#{@field.key}"}
|
||||
value={@value}
|
||||
class="admin-input"
|
||||
phx-debounce="300"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@@ -375,4 +580,46 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
_ -> block["type"]
|
||||
end
|
||||
end
|
||||
|
||||
defp coerce_settings(params, schema) do
|
||||
type_map = Map.new(schema, fn field -> {field.key, field} end)
|
||||
|
||||
Map.new(params, fn {key, value} ->
|
||||
case type_map[key] do
|
||||
%{type: :number, default: default} ->
|
||||
{key, parse_number(value, default)}
|
||||
|
||||
_ ->
|
||||
{key, value}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_number(value, default) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{n, ""} -> n
|
||||
_ -> default
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_number(value, _default) when is_integer(value), do: value
|
||||
defp parse_number(_value, default), do: default
|
||||
|
||||
defp has_settings?(block) do
|
||||
case BlockTypes.get(block["type"]) do
|
||||
%{settings_schema: [_ | _]} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp settings_with_defaults(block) do
|
||||
schema =
|
||||
case BlockTypes.get(block["type"]) do
|
||||
%{settings_schema: s} -> s
|
||||
_ -> []
|
||||
end
|
||||
|
||||
defaults = Map.new(schema, fn field -> {field.key, field.default} end)
|
||||
Map.merge(defaults, block["settings"] || %{})
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user