add live page editor sidebar with collapsible UI
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:
jamey
2026-02-27 16:22:35 +00:00
parent b340c24aa1
commit a039c8d53c
12 changed files with 1846 additions and 640 deletions

View File

@@ -0,0 +1,255 @@
defmodule Berrypod.Pages.BlockEditor do
@moduledoc """
Pure functions for block list manipulation.
Used by both the admin page editor and the live page editor sidebar.
No side effects — takes a block list in, returns a block list out.
"""
alias Berrypod.Pages.{BlockTypes, Defaults}
# ── Block operations ─────────────────────────────────────────────
def move_up(blocks, block_id) do
idx = Enum.find_index(blocks, &(&1["id"] == block_id))
if idx && idx > 0 do
block = Enum.at(blocks, idx)
name = block_display_name(block)
new_blocks =
blocks
|> List.delete_at(idx)
|> List.insert_at(idx - 1, block)
{:ok, new_blocks, "#{name} moved to position #{idx}"}
else
:noop
end
end
def move_down(blocks, block_id) do
idx = Enum.find_index(blocks, &(&1["id"] == block_id))
if idx && idx < length(blocks) - 1 do
block = Enum.at(blocks, idx)
name = block_display_name(block)
new_blocks =
blocks
|> List.delete_at(idx)
|> List.insert_at(idx + 1, block)
{:ok, new_blocks, "#{name} moved to position #{idx + 2}"}
else
:noop
end
end
def remove_block(blocks, block_id) do
block = Enum.find(blocks, &(&1["id"] == block_id))
name = block_display_name(block)
new_blocks = Enum.reject(blocks, &(&1["id"] == block_id))
{:ok, new_blocks, "#{name} removed"}
end
def duplicate_block(blocks, block_id) do
idx = Enum.find_index(blocks, &(&1["id"] == block_id))
if idx do
original = Enum.at(blocks, idx)
copy = %{
"id" => Defaults.generate_block_id(),
"type" => original["type"],
"settings" => original["settings"] || %{}
}
name = block_display_name(original)
new_blocks = List.insert_at(blocks, idx + 1, copy)
{:ok, new_blocks, "#{name} duplicated"}
else
:noop
end
end
def add_block(blocks, type) do
block_def = BlockTypes.get(type)
if block_def do
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
}
{:ok, blocks ++ [new_block], "#{block_def.name} added"}
else
:noop
end
end
def update_settings(blocks, block_id, new_settings) do
block = Enum.find(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(blocks, fn b ->
if b["id"] == block_id do
Map.put(b, "settings", Map.merge(b["settings"] || %{}, coerced))
else
b
end
end)
{:ok, new_blocks}
else
:noop
end
end
# ── Repeater operations ──────────────────────────────────────────
def repeater_add(blocks, block_id, field_key) do
block = Enum.find(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(blocks, block_id, field_key, new_items)
{:ok, new_blocks, "Item added"}
else
:noop
end
end
def repeater_remove(blocks, block_id, field_key, index) do
block = Enum.find(blocks, &(&1["id"] == block_id))
if block do
items = (block["settings"] || %{})[field_key] || []
new_items = List.delete_at(items, index)
new_blocks = update_block_field(blocks, block_id, field_key, new_items)
{:ok, new_blocks, "Item removed"}
else
:noop
end
end
def repeater_move(blocks, block_id, field_key, index, direction) do
block = Enum.find(blocks, &(&1["id"] == block_id))
if block do
items = (block["settings"] || %{})[field_key] || []
target = if direction == "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(blocks, block_id, field_key, new_items)
{:ok, new_blocks, "Item moved #{direction}"}
else
:noop
end
else
:noop
end
end
# ── Helpers ──────────────────────────────────────────────────────
def block_display_name(nil), do: "Block"
def block_display_name(block) do
case BlockTypes.get(block["type"]) do
%{name: name} -> name
_ -> block["type"]
end
end
def has_settings?(block) do
case BlockTypes.get(block["type"]) do
%{settings_schema: [_ | _]} -> true
_ -> false
end
end
def 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
def 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
def parse_number(value, default) when is_binary(value) do
case Integer.parse(value) do
{n, ""} -> n
_ -> default
end
end
def parse_number(value, _default) when is_integer(value), do: value
def parse_number(_value, default), do: default
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 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
end

View File

@@ -233,8 +233,9 @@ defmodule Berrypod.Pages.BlockTypes do
"content_body" => %{
name: "Page content",
icon: "hero-document-text",
allowed_on: ["about", "delivery", "privacy", "terms"],
allowed_on: :all,
settings_schema: [
%SettingsField{key: "content", label: "Content", type: :textarea, default: ""},
%SettingsField{key: "image_src", label: "Image", type: :text, default: ""},
%SettingsField{key: "image_alt", label: "Image alt text", type: :text, default: ""}
]