add live page editor sidebar with collapsible UI
All checks were successful
deploy / deploy (push) Successful in 6m49s
All checks were successful
deploy / deploy (push) Successful in 6m49s
Admins can now edit pages directly on the live shop by clicking the pencil icon in the header. A sidebar slides in with block management controls (add, remove, reorder, edit settings, save, reset, done). Key features: - PageEditorHook on_mount with handle_params/event/info hooks - BlockEditor pure functions extracted from admin editor - Shared BlockEditorComponents with event_prefix namespacing - Collapsible sidebar: X closes it, header pencil reopens it - Backdrop overlay dismisses sidebar on tap - Conditional admin.css loading for logged-in users - content_body block now portable (textarea setting + rich text fallback) 13 integration tests, 26 unit tests, 1370 total passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.{Media, Pages}
|
||||
alias Berrypod.Pages.{BlockTypes, Defaults}
|
||||
alias Berrypod.Pages.{BlockEditor, BlockTypes}
|
||||
alias Berrypod.Products.ProductImage
|
||||
alias Berrypod.Theme.{Fonts, PreviewData}
|
||||
|
||||
import BerrypodWeb.BlockEditorComponents
|
||||
|
||||
@impl true
|
||||
def mount(%{"slug" => slug}, _session, socket) do
|
||||
page = Pages.get_page(slug)
|
||||
@@ -35,97 +37,125 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
|> assign(:header_image, Media.get_header())}
|
||||
end
|
||||
|
||||
# ── Block manipulation events ────────────────────────────────────
|
||||
|
||||
@impl true
|
||||
def handle_event("move_up", %{"id" => block_id}, socket) do
|
||||
blocks = socket.assigns.blocks
|
||||
idx = Enum.find_index(blocks, &(&1["id"] == block_id))
|
||||
case BlockEditor.move_up(socket.assigns.blocks, block_id) do
|
||||
{:ok, new_blocks, message} ->
|
||||
{:noreply, apply_mutation(socket, new_blocks, message)}
|
||||
|
||||
if idx && idx > 0 do
|
||||
block = Enum.at(blocks, idx)
|
||||
block_name = block_display_name(block)
|
||||
new_pos = idx
|
||||
|
||||
new_blocks =
|
||||
blocks
|
||||
|> List.delete_at(idx)
|
||||
|> List.insert_at(idx - 1, block)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, new_blocks)
|
||||
|> assign(:dirty, true)
|
||||
|> assign(:live_region_message, "#{block_name} moved to position #{new_pos}")}
|
||||
else
|
||||
{:noreply, socket}
|
||||
:noop ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("move_down", %{"id" => block_id}, socket) do
|
||||
blocks = socket.assigns.blocks
|
||||
idx = Enum.find_index(blocks, &(&1["id"] == block_id))
|
||||
case BlockEditor.move_down(socket.assigns.blocks, block_id) do
|
||||
{:ok, new_blocks, message} ->
|
||||
{:noreply, apply_mutation(socket, new_blocks, message)}
|
||||
|
||||
if idx && idx < length(blocks) - 1 do
|
||||
block = Enum.at(blocks, idx)
|
||||
block_name = block_display_name(block)
|
||||
new_pos = idx + 2
|
||||
|
||||
new_blocks =
|
||||
blocks
|
||||
|> List.delete_at(idx)
|
||||
|> List.insert_at(idx + 1, block)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, new_blocks)
|
||||
|> assign(:dirty, true)
|
||||
|> assign(:live_region_message, "#{block_name} moved to position #{new_pos}")}
|
||||
else
|
||||
{:noreply, socket}
|
||||
:noop ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("remove_block", %{"id" => block_id}, socket) do
|
||||
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
|
||||
block_name = block_display_name(block)
|
||||
new_blocks = Enum.reject(socket.assigns.blocks, &(&1["id"] == block_id))
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, new_blocks)
|
||||
|> assign(:dirty, true)
|
||||
|> assign(:live_region_message, "#{block_name} removed")}
|
||||
{:ok, new_blocks, message} = BlockEditor.remove_block(socket.assigns.blocks, block_id)
|
||||
{:noreply, apply_mutation(socket, new_blocks, message)}
|
||||
end
|
||||
|
||||
def handle_event("duplicate_block", %{"id" => block_id}, socket) do
|
||||
blocks = socket.assigns.blocks
|
||||
idx = Enum.find_index(blocks, &(&1["id"] == block_id))
|
||||
case BlockEditor.duplicate_block(socket.assigns.blocks, block_id) do
|
||||
{:ok, new_blocks, message} ->
|
||||
{:noreply, apply_mutation(socket, new_blocks, message)}
|
||||
|
||||
if idx do
|
||||
original = Enum.at(blocks, idx)
|
||||
|
||||
copy = %{
|
||||
"id" => Defaults.generate_block_id(),
|
||||
"type" => original["type"],
|
||||
"settings" => original["settings"] || %{}
|
||||
}
|
||||
|
||||
block_name = block_display_name(original)
|
||||
new_blocks = List.insert_at(blocks, idx + 1, copy)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, new_blocks)
|
||||
|> assign(:dirty, true)
|
||||
|> assign(:live_region_message, "#{block_name} duplicated")}
|
||||
else
|
||||
{:noreply, socket}
|
||||
:noop ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("add_block", %{"type" => type}, socket) do
|
||||
case BlockEditor.add_block(socket.assigns.blocks, type) do
|
||||
{:ok, new_blocks, message} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, new_blocks)
|
||||
|> assign(:dirty, true)
|
||||
|> assign(:show_picker, false)
|
||||
|> assign(:live_region_message, message)}
|
||||
|
||||
:noop ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("update_block_settings", params, socket) do
|
||||
block_id = params["block_id"]
|
||||
new_settings = params["block_settings"] || %{}
|
||||
|
||||
case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do
|
||||
{:ok, new_blocks} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, new_blocks)
|
||||
|> assign(:dirty, true)}
|
||||
|
||||
:noop ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# ── Repeater events ──────────────────────────────────────────────
|
||||
|
||||
def handle_event("repeater_add", %{"block-id" => block_id, "field" => field_key}, socket) do
|
||||
case BlockEditor.repeater_add(socket.assigns.blocks, block_id, field_key) do
|
||||
{:ok, new_blocks, message} ->
|
||||
{:noreply, apply_mutation(socket, new_blocks, message)}
|
||||
|
||||
:noop ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"repeater_remove",
|
||||
%{"block-id" => block_id, "field" => field_key, "index" => index_str},
|
||||
socket
|
||||
) do
|
||||
index = String.to_integer(index_str)
|
||||
|
||||
case BlockEditor.repeater_remove(socket.assigns.blocks, block_id, field_key, index) do
|
||||
{:ok, new_blocks, message} ->
|
||||
{:noreply, apply_mutation(socket, new_blocks, message)}
|
||||
|
||||
:noop ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"repeater_move",
|
||||
%{"block-id" => block_id, "field" => field_key, "index" => index_str, "dir" => dir},
|
||||
socket
|
||||
) do
|
||||
index = String.to_integer(index_str)
|
||||
|
||||
case BlockEditor.repeater_move(socket.assigns.blocks, block_id, field_key, index, dir) do
|
||||
{:ok, new_blocks, message} ->
|
||||
{:noreply, apply_mutation(socket, new_blocks, message)}
|
||||
|
||||
:noop ->
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# ── UI events ────────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
block_name = BlockEditor.block_display_name(block)
|
||||
|
||||
{new_expanded, action} =
|
||||
if MapSet.member?(expanded, block_id) do
|
||||
@@ -140,122 +170,6 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
|> 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("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
|
||||
@@ -271,33 +185,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
{:noreply, assign(socket, :picker_filter, value)}
|
||||
end
|
||||
|
||||
def handle_event("add_block", %{"type" => type}, socket) do
|
||||
block_def = BlockTypes.get(type)
|
||||
|
||||
if block_def do
|
||||
# Build default settings from schema
|
||||
default_settings =
|
||||
block_def
|
||||
|> Map.get(:settings_schema, [])
|
||||
|> Enum.into(%{}, fn field -> {field.key, field.default} end)
|
||||
|
||||
new_block = %{
|
||||
"id" => Defaults.generate_block_id(),
|
||||
"type" => type,
|
||||
"settings" => default_settings
|
||||
}
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:blocks, socket.assigns.blocks ++ [new_block])
|
||||
|> assign(:dirty, true)
|
||||
|> assign(:show_picker, false)
|
||||
|> assign(:live_region_message, "#{block_def.name} added")}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
def handle_event("toggle_preview", _params, socket) do
|
||||
{:noreply, assign(socket, :show_preview, !socket.assigns.show_preview)}
|
||||
end
|
||||
|
||||
# ── Page actions ─────────────────────────────────────────────────
|
||||
|
||||
def handle_event("save", _params, socket) do
|
||||
%{slug: slug, blocks: blocks, page_data: page_data} = socket.assigns
|
||||
|
||||
@@ -325,9 +218,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
|> put_flash(:info, "Page reset to defaults")}
|
||||
end
|
||||
|
||||
def handle_event("toggle_preview", _params, socket) do
|
||||
{:noreply, assign(socket, :show_preview, !socket.assigns.show_preview)}
|
||||
end
|
||||
# ── Render ───────────────────────────────────────────────────────
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
@@ -439,7 +330,6 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
# ── Preview ───────────────────────────────────────────────────────
|
||||
|
||||
defp preview_pane(assigns) do
|
||||
# Build a temporary page struct from working state
|
||||
page = %{slug: assigns.slug, title: assigns.page_data.title, blocks: assigns.blocks}
|
||||
|
||||
preview =
|
||||
@@ -554,427 +444,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
# ── Block card ─────────────────────────────────────────────────────
|
||||
# ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
defp block_card(assigns) do
|
||||
block_type = BlockTypes.get(assigns.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", @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"]}"}
|
||||
>
|
||||
<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-name">
|
||||
{(@block_type && @block_type.name) || @block["type"]}
|
||||
</span>
|
||||
|
||||
<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"
|
||||
>
|
||||
{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: :repeater}} = assigns) do
|
||||
items = if is_list(assigns.value), do: assigns.value, else: []
|
||||
item_count = length(items)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:items, items)
|
||||
|> assign(:item_count, item_count)
|
||||
|
||||
~H"""
|
||||
<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">
|
||||
<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
|
||||
|
||||
defp block_picker(assigns) do
|
||||
filter = String.downcase(assigns.filter)
|
||||
|
||||
filtered =
|
||||
assigns.allowed_blocks
|
||||
|> Enum.filter(fn {_type, def} ->
|
||||
filter == "" or String.contains?(String.downcase(def.name), filter)
|
||||
end)
|
||||
|> Enum.sort_by(fn {_type, def} -> def.name end)
|
||||
|
||||
assigns = assign(assigns, :filtered_blocks, filtered)
|
||||
|
||||
~H"""
|
||||
<div class="block-picker-overlay" phx-click="hide_picker">
|
||||
<div class="block-picker" phx-click-away="hide_picker">
|
||||
<div class="block-picker-header">
|
||||
<h3>Add a block</h3>
|
||||
<button
|
||||
phx-click="hide_picker"
|
||||
class="admin-btn admin-btn-ghost admin-btn-icon admin-btn-sm"
|
||||
aria-label="Close"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter blocks..."
|
||||
value={@filter}
|
||||
phx-keyup="filter_picker"
|
||||
phx-key=""
|
||||
class="admin-input block-picker-search"
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<div class="block-picker-grid">
|
||||
<button
|
||||
:for={{type, def} <- @filtered_blocks}
|
||||
phx-click="add_block"
|
||||
phx-value-type={type}
|
||||
class="block-picker-item"
|
||||
>
|
||||
<.icon name={def.icon} class="size-5" />
|
||||
<span>{def.name}</span>
|
||||
</button>
|
||||
|
||||
<p :if={@filtered_blocks == []} class="block-picker-empty">
|
||||
No matching blocks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp block_display_name(nil), do: "Block"
|
||||
|
||||
defp block_display_name(block) do
|
||||
case BlockTypes.get(block["type"]) do
|
||||
%{name: name} -> name
|
||||
_ -> 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)}
|
||||
|
||||
%{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
|
||||
_ -> 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"] || %{})
|
||||
defp apply_mutation(socket, new_blocks, message) do
|
||||
socket
|
||||
|> assign(:blocks, new_blocks)
|
||||
|> assign(:dirty, true)
|
||||
|> assign(:live_region_message, message)
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user