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: ""}
]

View File

@@ -0,0 +1,405 @@
defmodule BerrypodWeb.BlockEditorComponents do
@moduledoc """
Shared UI components for the block editor.
Used by both the admin page editor (`Admin.Pages.Editor`) and the
live page editor sidebar (`PageEditorHook` + `PageRenderer`).
All components accept an `event_prefix` attr to namespace phx-click
events. The admin editor uses `""` (default), the live sidebar uses
`"editor_"`.
"""
use Phoenix.Component
import BerrypodWeb.CoreComponents, only: [icon: 1]
alias Berrypod.Pages.{BlockEditor, BlockTypes}
# ── Block card ─────────────────────────────────────────────────
attr :block, :map, required: true
attr :idx, :integer, required: true
attr :total, :integer, required: true
attr :expanded, :any, required: true
attr :event_prefix, :string, default: ""
def block_card(assigns) do
block_type = BlockTypes.get(assigns.block["type"])
has_settings = BlockEditor.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={"#{@event_prefix}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={"#{@event_prefix}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={"#{@event_prefix}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={"#{@event_prefix}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={"#{@event_prefix}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}
event_prefix={@event_prefix}
/>
</div>
"""
end
# ── Settings form ──────────────────────────────────────────────
attr :block, :map, required: true
attr :schema, :list, required: true
attr :event_prefix, :string, default: ""
def block_settings_form(assigns) do
settings = BlockEditor.settings_with_defaults(assigns.block)
assigns = assign(assigns, :settings, settings)
~H"""
<div class="block-card-settings" id={"block-settings-#{@block["id"]}"}>
<form phx-change={"#{@event_prefix}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"]}
event_prefix={@event_prefix}
/>
</div>
</form>
</div>
"""
end
# ── Field renderers ────────────────────────────────────────────
attr :field, :any, required: true
attr :value, :any, required: true
attr :block_id, :string, required: true
attr :event_prefix, :string, default: ""
def 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
def 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
def 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
def 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}
event_prefix={@event_prefix}
/>
</div>
<div class="repeater-item-controls">
<button
type="button"
phx-click={"#{@event_prefix}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={"#{@event_prefix}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={"#{@event_prefix}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={"#{@event_prefix}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
def 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
# ── Repeater item field ────────────────────────────────────────
attr :sub_field, :any, required: true
attr :item, :map, required: true
attr :field_key, :string, required: true
attr :block_id, :string, required: true
attr :index, :integer, required: true
attr :event_prefix, :string, default: ""
def 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
# ── Block picker ───────────────────────────────────────────────
attr :allowed_blocks, :any, required: true
attr :filter, :string, required: true
attr :event_prefix, :string, default: ""
def 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">
<div class="block-picker" phx-click-away={"#{@event_prefix}hide_picker"}>
<div class="block-picker-header">
<h3>Add a block</h3>
<button
phx-click={"#{@event_prefix}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={"#{@event_prefix}filter_picker"}
phx-key=""
class="admin-input block-picker-search"
autofocus
/>
<div class="block-picker-grid">
<button
:for={{type, def} <- @filtered_blocks}
phx-click={"#{@event_prefix}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
end

View File

@@ -63,6 +63,9 @@
@layer properties, reset, primitives, tokens, theme, base, components, layout, utilities, overrides;
</style>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/shop.css"} />
<%= if assigns[:current_scope] && @current_scope.user do %>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/admin.css"} />
<% end %>
<script defer phx-track-static src={~p"/assets/js/app.js"}>
</script>
<!-- Generated theme CSS with @font-face declarations -->

View File

@@ -51,7 +51,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
@layout_keys ~w(theme_settings logo_image header_image mode cart_items cart_count
cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
search_query search_results search_open categories shipping_estimate
country_code available_countries)a
country_code available_countries editing editor_current_path editor_sidebar_open)a
@doc """
Extracts the assigns relevant to `shop_layout` from a full assigns map.
@@ -86,6 +86,9 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :active_page, :string, required: true
attr :error_page, :boolean, default: false
attr :is_admin, :boolean, default: false
attr :editing, :boolean, default: false
attr :editor_current_path, :string, default: nil
attr :editor_sidebar_open, :boolean, default: true
attr :search_query, :string, default: ""
attr :search_results, :list, default: []
attr :search_open, :boolean, default: false
@@ -117,6 +120,9 @@ defmodule BerrypodWeb.ShopComponents.Layout do
mode={@mode}
cart_count={@cart_count}
is_admin={@is_admin}
editing={@editing}
editor_current_path={@editor_current_path}
editor_sidebar_open={@editor_sidebar_open}
/>
{render_slot(@inner_block)}
@@ -685,6 +691,9 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :mode, :atom, default: :live
attr :cart_count, :integer, default: 0
attr :is_admin, :boolean, default: false
attr :editing, :boolean, default: false
attr :editor_current_path, :string, default: nil
attr :editor_sidebar_open, :boolean, default: true
def shop_header(assigns) do
~H"""
@@ -729,6 +738,23 @@ defmodule BerrypodWeb.ShopComponents.Layout do
</nav>
<div class="shop-actions">
<%!-- Pencil icon: enters edit mode, or re-opens sidebar if already editing --%>
<.link
:if={@is_admin && !@editing && @editor_current_path}
patch={"#{@editor_current_path}?edit=true"}
class="header-icon-btn"
aria-label="Edit page"
>
<.edit_pencil_svg />
</.link>
<button
:if={@is_admin && @editing && !@editor_sidebar_open}
phx-click="editor_toggle_sidebar"
class="header-icon-btn"
aria-label="Show editor sidebar"
>
<.edit_pencil_svg />
</button>
<.link
:if={@is_admin}
href="/admin"
@@ -933,6 +959,25 @@ defmodule BerrypodWeb.ShopComponents.Layout do
"""
end
defp edit_pencil_svg(assigns) do
~H"""
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
"""
end
defp open_cart_drawer_js do
Phoenix.LiveView.JS.push("open_cart_drawer")
end

View File

@@ -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

View File

@@ -0,0 +1,360 @@
defmodule BerrypodWeb.PageEditorHook do
@moduledoc """
LiveView on_mount hook for the live page editor sidebar.
Mounted in the public_shop live_session. When an admin visits any shop
page with `?edit=true` in the URL, this hook activates editing mode:
loads a working copy of the page's blocks, attaches event handlers for
block manipulation, and sets assigns that trigger the editor sidebar
in `PageRenderer.render_page/1`.
Non-admin users are unaffected — the hook just assigns `editing: false`.
## Actions
- `:mount_page_editor` — sets up editing assigns and attaches hooks
"""
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [attach_hook: 4, put_flash: 3, push_navigate: 2]
alias Berrypod.Pages
alias Berrypod.Pages.{BlockEditor, BlockTypes}
def on_mount(:mount_page_editor, _params, _session, socket) do
socket =
socket
|> assign(:editing, false)
|> assign(:editing_blocks, nil)
|> assign(:editor_dirty, false)
|> assign(:editor_expanded, MapSet.new())
|> assign(:editor_show_picker, false)
|> assign(:editor_picker_filter, "")
|> assign(:editor_allowed_blocks, nil)
|> assign(:editor_live_region_message, nil)
|> assign(:editor_current_path, nil)
|> assign(:editor_sidebar_open, true)
|> attach_hook(:editor_params, :handle_params, &handle_editor_params/3)
|> attach_hook(:editor_events, :handle_event, &handle_editor_event/3)
|> attach_hook(:editor_info, :handle_info, &handle_editor_info/2)
{:cont, socket}
end
# ── handle_params: detect ?edit=true ─────────────────────────────
defp handle_editor_params(_params, uri, socket) do
parsed = URI.parse(uri)
query = URI.decode_query(parsed.query || "")
wants_edit = query["edit"] == "true"
# Always store the current path for the edit button and "done" navigation
socket = assign(socket, :editor_current_path, parsed.path)
cond do
wants_edit and socket.assigns.is_admin and socket.assigns[:page] ->
# Page already loaded — enter edit mode and halt (no need for module handle_params)
{:halt, enter_edit_mode(socket)}
wants_edit and socket.assigns.is_admin ->
# Page not loaded yet (e.g. Shop.Content loads in handle_params),
# defer initialisation until after the LiveView sets @page
send(self(), :editor_deferred_init)
{:cont, assign(socket, :editing, true)}
socket.assigns.editing and not wants_edit ->
# Exiting edit mode — halt since we've handled the transition
{:halt, exit_edit_mode(socket)}
true ->
{:cont, socket}
end
end
# ── handle_info: deferred init ───────────────────────────────────
defp handle_editor_info(:editor_deferred_init, socket) do
if socket.assigns.editing and is_nil(socket.assigns.editing_blocks) and socket.assigns[:page] do
{:halt, enter_edit_mode(socket)}
else
{:cont, socket}
end
end
defp handle_editor_info(_msg, socket), do: {:cont, socket}
# ── handle_event: editor_* events ────────────────────────────────
defp handle_editor_event("editor_" <> action, params, socket) do
if socket.assigns.editing do
handle_editor_action(action, params, socket)
else
{:cont, socket}
end
end
defp handle_editor_event(_event, _params, socket), do: {:cont, socket}
# ── Block manipulation actions ───────────────────────────────────
defp handle_editor_action("move_up", %{"id" => id}, socket) do
case BlockEditor.move_up(socket.assigns.editing_blocks, id) do
{:ok, new_blocks, message} ->
{:halt, apply_mutation(socket, new_blocks, message, :reorder)}
:noop ->
{:halt, socket}
end
end
defp handle_editor_action("move_down", %{"id" => id}, socket) do
case BlockEditor.move_down(socket.assigns.editing_blocks, id) do
{:ok, new_blocks, message} ->
{:halt, apply_mutation(socket, new_blocks, message, :reorder)}
:noop ->
{:halt, socket}
end
end
defp handle_editor_action("remove_block", %{"id" => id}, socket) do
{:ok, new_blocks, message} = BlockEditor.remove_block(socket.assigns.editing_blocks, id)
{:halt, apply_mutation(socket, new_blocks, message, :content)}
end
defp handle_editor_action("duplicate_block", %{"id" => id}, socket) do
case BlockEditor.duplicate_block(socket.assigns.editing_blocks, id) do
{:ok, new_blocks, message} ->
{:halt, apply_mutation(socket, new_blocks, message, :content)}
:noop ->
{:halt, socket}
end
end
defp handle_editor_action("add_block", %{"type" => type}, socket) do
case BlockEditor.add_block(socket.assigns.editing_blocks, type) do
{:ok, new_blocks, message} ->
socket =
socket
|> assign(:editing_blocks, new_blocks)
|> assign(:editor_dirty, true)
|> assign(:editor_show_picker, false)
|> assign(:editor_live_region_message, message)
|> reload_block_data(new_blocks)
{:halt, socket}
:noop ->
{:halt, socket}
end
end
defp handle_editor_action("update_block_settings", params, socket) do
block_id = params["block_id"]
new_settings = params["block_settings"] || %{}
case BlockEditor.update_settings(socket.assigns.editing_blocks, block_id, new_settings) do
{:ok, new_blocks} ->
socket =
socket
|> assign(:editing_blocks, new_blocks)
|> assign(:editor_dirty, true)
|> reload_block_data(new_blocks)
{:halt, socket}
:noop ->
{:halt, socket}
end
end
# ── Repeater actions ─────────────────────────────────────────────
defp handle_editor_action(
"repeater_add",
%{"block-id" => block_id, "field" => field_key},
socket
) do
case BlockEditor.repeater_add(socket.assigns.editing_blocks, block_id, field_key) do
{:ok, new_blocks, message} ->
{:halt, apply_mutation(socket, new_blocks, message, :content)}
:noop ->
{:halt, socket}
end
end
defp handle_editor_action(
"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.editing_blocks, block_id, field_key, index) do
{:ok, new_blocks, message} ->
{:halt, apply_mutation(socket, new_blocks, message, :content)}
:noop ->
{:halt, socket}
end
end
defp handle_editor_action(
"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.editing_blocks,
block_id,
field_key,
index,
dir
) do
{:ok, new_blocks, message} ->
{:halt, apply_mutation(socket, new_blocks, message, :reorder)}
:noop ->
{:halt, socket}
end
end
# ── UI state actions ─────────────────────────────────────────────
defp handle_editor_action("toggle_expand", %{"id" => block_id}, socket) do
expanded = socket.assigns.editor_expanded
block = Enum.find(socket.assigns.editing_blocks, &(&1["id"] == block_id))
block_name = BlockEditor.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
{:halt,
socket
|> assign(:editor_expanded, new_expanded)
|> assign(:editor_live_region_message, "#{block_name} settings #{action}")}
end
defp handle_editor_action("toggle_sidebar", _params, socket) do
{:halt, assign(socket, :editor_sidebar_open, !socket.assigns.editor_sidebar_open)}
end
defp handle_editor_action("show_picker", _params, socket) do
{:halt,
socket
|> assign(:editor_show_picker, true)
|> assign(:editor_picker_filter, "")}
end
defp handle_editor_action("hide_picker", _params, socket) do
{:halt, assign(socket, :editor_show_picker, false)}
end
defp handle_editor_action("filter_picker", %{"value" => value}, socket) do
{:halt, assign(socket, :editor_picker_filter, value)}
end
# ── Page actions ─────────────────────────────────────────────────
defp handle_editor_action("save", _params, socket) do
%{page: page, editing_blocks: blocks} = socket.assigns
case Pages.save_page(page.slug, %{title: page.title, blocks: blocks}) do
{:ok, _saved_page} ->
updated_page = Pages.get_page(page.slug)
socket =
socket
|> assign(:page, updated_page)
|> assign(:editing_blocks, updated_page.blocks)
|> assign(:editor_dirty, false)
|> put_flash(:info, "Page saved")
{:halt, socket}
{:error, _changeset} ->
{:halt, put_flash(socket, :error, "Failed to save page")}
end
end
defp handle_editor_action("reset_defaults", _params, socket) do
slug = socket.assigns.page.slug
:ok = Pages.reset_page(slug)
page = Pages.get_page(slug)
socket =
socket
|> assign(:page, page)
|> assign(:editing_blocks, page.blocks)
|> assign(:editor_dirty, false)
|> reload_block_data(page.blocks)
|> put_flash(:info, "Page reset to defaults")
{:halt, socket}
end
defp handle_editor_action("done", _params, socket) do
path = socket.assigns.editor_current_path || "/"
{:halt, push_navigate(socket, to: path)}
end
# Catch-all for unknown editor actions
defp handle_editor_action(_action, _params, socket), do: {:halt, socket}
# ── Private helpers ──────────────────────────────────────────────
defp enter_edit_mode(socket) do
page = socket.assigns.page
allowed = BlockTypes.allowed_for(page.slug)
socket
|> assign(:editing, true)
|> assign(:editing_blocks, page.blocks)
|> assign(:editor_dirty, false)
|> assign(:editor_expanded, MapSet.new())
|> assign(:editor_show_picker, false)
|> assign(:editor_picker_filter, "")
|> assign(:editor_allowed_blocks, allowed)
|> assign(:editor_live_region_message, nil)
|> assign(:editor_sidebar_open, true)
end
defp exit_edit_mode(socket) do
socket
|> assign(:editing, false)
|> assign(:editing_blocks, nil)
|> assign(:editor_dirty, false)
|> assign(:editor_expanded, MapSet.new())
|> assign(:editor_show_picker, false)
|> assign(:editor_picker_filter, "")
|> assign(:editor_allowed_blocks, nil)
|> assign(:editor_live_region_message, nil)
|> assign(:editor_sidebar_open, true)
end
defp apply_mutation(socket, new_blocks, message, type) do
socket =
socket
|> assign(:editing_blocks, new_blocks)
|> assign(:editor_dirty, true)
|> assign(:editor_live_region_message, message)
case type do
:content -> reload_block_data(socket, new_blocks)
:reorder -> socket
end
end
defp reload_block_data(socket, blocks) do
extra = Pages.load_block_data(blocks, socket.assigns)
Enum.reduce(extra, socket, fn {key, value}, acc -> assign(acc, key, value) end)
end
end

View File

@@ -15,6 +15,9 @@ defmodule BerrypodWeb.PageRenderer do
router: BerrypodWeb.Router,
statics: BerrypodWeb.static_paths()
import BerrypodWeb.BlockEditorComponents
import BerrypodWeb.CoreComponents, only: [icon: 1]
alias Berrypod.Cart
# ── Public API ──────────────────────────────────────────────────
@@ -24,8 +27,19 @@ defmodule BerrypodWeb.PageRenderer do
Expects `@page` (with `:slug` and `:blocks`) plus all the standard
layout assigns (theme_settings, cart_items, etc.).
When `@editing` is true and `@editing_blocks` is set (admin using the
live page editor), wraps the page in a sidebar + content layout.
"""
def render_page(assigns) do
if assigns[:editing] && assigns[:editing_blocks] do
render_page_with_editor(assigns)
else
render_page_normal(assigns)
end
end
defp render_page_normal(assigns) do
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
~H"""
@@ -43,6 +57,122 @@ defmodule BerrypodWeb.PageRenderer do
"""
end
defp render_page_with_editor(assigns) do
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
~H"""
<div
id="page-editor-live"
class="page-editor-live"
phx-hook="DirtyGuard"
data-dirty={to_string(@editor_dirty)}
data-sidebar-open={to_string(@editor_sidebar_open)}
>
<aside class="page-editor-sidebar" aria-label="Page editor">
<div class="page-editor-sidebar-header">
<h2 class="page-editor-sidebar-title">{@page.title}</h2>
<div class="page-editor-sidebar-actions">
<button
phx-click="editor_save"
class={[
"admin-btn admin-btn-sm admin-btn-primary",
!@editor_dirty && "opacity-50"
]}
disabled={!@editor_dirty}
>
Save
</button>
<button
phx-click="editor_reset_defaults"
data-confirm="Reset this page to its default layout? Your changes will be lost."
class="admin-btn admin-btn-sm admin-btn-ghost"
>
Reset
</button>
<button phx-click="editor_done" class="admin-btn admin-btn-sm admin-btn-ghost">
Done
</button>
<button
phx-click="editor_toggle_sidebar"
class="admin-btn admin-btn-sm admin-btn-ghost"
aria-label="Close sidebar"
>
<.icon name="hero-x-mark" class="size-5" />
</button>
</div>
</div>
<%!-- ARIA live region for screen reader announcements --%>
<div aria-live="polite" aria-atomic="true" class="sr-only">
{if @editor_live_region_message, do: @editor_live_region_message}
</div>
<%!-- Unsaved changes indicator --%>
<p :if={@editor_dirty} class="admin-badge admin-badge-warning page-editor-sidebar-dirty">
Unsaved changes
</p>
<%!-- Block list --%>
<div class="block-list" role="list" aria-label="Page blocks">
<.block_card
:for={{block, idx} <- Enum.with_index(@editing_blocks)}
block={block}
idx={idx}
total={length(@editing_blocks)}
expanded={@editor_expanded}
event_prefix="editor_"
/>
<div :if={@editing_blocks == []} class="block-list-empty">
<p>No blocks on this page yet.</p>
</div>
</div>
<%!-- Add block button --%>
<div class="block-actions">
<button phx-click="editor_show_picker" class="admin-btn admin-btn-outline block-add-btn">
<.icon name="hero-plus" class="size-4" /> Add block
</button>
</div>
<%!-- Block picker modal --%>
<.block_picker
:if={@editor_show_picker}
allowed_blocks={@editor_allowed_blocks}
filter={@editor_picker_filter}
event_prefix="editor_"
/>
</aside>
<%!-- Backdrop: tapping the page dismisses the sidebar --%>
<div
:if={@editor_sidebar_open}
class="page-editor-backdrop"
phx-click="editor_toggle_sidebar"
aria-hidden="true"
/>
<div class="page-editor-content">
<.shop_layout
{layout_assigns(assigns)}
active_page={@page.slug}
error_page={@page.slug == "error"}
>
<main id="main-content" class={page_main_class(@page.slug)}>
<div
:for={block <- @editing_blocks}
:key={block["id"]}
data-block-type={block["type"]}
>
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
</div>
</main>
</.shop_layout>
</div>
</div>
"""
end
# ── Block dispatch ──────────────────────────────────────────────
defp render_block(%{block: %{"type" => "hero"}} = assigns) do
@@ -429,11 +559,13 @@ defmodule BerrypodWeb.PageRenderer do
defp render_block(%{block: %{"type" => "content_body"}} = assigns) do
settings = assigns.block["settings"] || %{}
content = settings["content"] || ""
assigns =
assigns
|> assign(:image_src, settings["image_src"])
|> assign(:image_alt, settings["image_alt"] || "")
|> assign(:content, content)
~H"""
<div class="content-body">
@@ -449,7 +581,17 @@ defmodule BerrypodWeb.PageRenderer do
</div>
<% end %>
<.rich_text blocks={assigns[:content_blocks] || []} />
<%= if @content != "" do %>
<%!-- Inline content from block settings — split on blank lines for paragraphs --%>
<div class="rich-text">
<p :for={para <- String.split(@content, ~r/\n{2,}/, trim: true)} class="rich-text-paragraph">
{para}
</p>
</div>
<% else %>
<%!-- Structured rich text from LiveView (content pages) --%>
<.rich_text blocks={assigns[:content_blocks] || []} />
<% end %>
</div>
"""
end

View File

@@ -70,7 +70,8 @@ defmodule BerrypodWeb.Router do
{BerrypodWeb.ThemeHook, :require_site_live},
{BerrypodWeb.CartHook, :mount_cart},
{BerrypodWeb.SearchHook, :mount_search},
{BerrypodWeb.AnalyticsHook, :track}
{BerrypodWeb.AnalyticsHook, :track},
{BerrypodWeb.PageEditorHook, :mount_page_editor}
] do
live "/", Shop.Home, :index
live "/about", Shop.Content, :about