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:
255
lib/berrypod/pages/block_editor.ex
Normal file
255
lib/berrypod/pages/block_editor.ex
Normal 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
|
||||
@@ -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: ""}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user