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

@ -183,6 +183,11 @@
font-size: 0.75rem; font-size: 0.75rem;
} }
.admin-btn-xs {
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
}
.admin-btn-icon { .admin-btn-icon {
padding: 0.5rem; padding: 0.5rem;
aspect-ratio: 1; aspect-ratio: 1;
@ -469,6 +474,11 @@
font-size: 0.625rem; font-size: 0.625rem;
} }
.admin-badge-warning {
background-color: color-mix(in oklch, var(--t-status-warning, #f59e0b) 15%, transparent);
color: var(--t-status-warning, #b45309);
}
/* ── Dropdown ── */ /* ── Dropdown ── */
.admin-dropdown { .admin-dropdown {
@ -1465,4 +1475,95 @@
border-style: dashed; border-style: dashed;
} }
/* ── Live editor layout (sidebar on shop pages) ── */
.page-editor-live {
display: flex;
min-height: 100vh;
}
.page-editor-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 360px;
background: var(--t-surface-base);
border-right: 1px solid var(--t-border-default);
overflow-y: auto;
z-index: 40;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
padding: 1rem;
transition: transform 0.25s ease;
transform: translateX(0);
}
/* Hidden sidebar — slides off-screen */
[data-sidebar-open="false"] .page-editor-sidebar {
transform: translateX(-100%);
box-shadow: none;
}
.page-editor-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.page-editor-sidebar-title {
font-size: 1rem;
font-weight: 600;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.page-editor-sidebar-actions {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.page-editor-sidebar-dirty {
margin-bottom: 0.5rem;
}
.page-editor-content {
flex: 1;
margin-left: 360px;
min-width: 0;
transition: margin-left 0.25s ease;
}
/* Content goes full-width when sidebar is hidden */
[data-sidebar-open="false"] .page-editor-content {
margin-left: 0;
}
/* Clickable backdrop to dismiss the sidebar */
.page-editor-backdrop {
position: fixed;
inset: 0;
z-index: 39;
background: rgba(0, 0, 0, 0.15);
cursor: pointer;
}
/* Mobile: sidebar overlays content, no margin push */
@media (max-width: 63.99em) {
.page-editor-sidebar {
width: 85%;
max-width: 360px;
}
.page-editor-content {
margin-left: 0;
}
}
} /* @layer admin */ } /* @layer admin */

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" => %{ "content_body" => %{
name: "Page content", name: "Page content",
icon: "hero-document-text", icon: "hero-document-text",
allowed_on: ["about", "delivery", "privacy", "terms"], allowed_on: :all,
settings_schema: [ settings_schema: [
%SettingsField{key: "content", label: "Content", type: :textarea, default: ""},
%SettingsField{key: "image_src", label: "Image", type: :text, default: ""}, %SettingsField{key: "image_src", label: "Image", type: :text, default: ""},
%SettingsField{key: "image_alt", label: "Image alt text", 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; @layer properties, reset, primitives, tokens, theme, base, components, layout, utilities, overrides;
</style> </style>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/shop.css"} /> <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 defer phx-track-static src={~p"/assets/js/app.js"}>
</script> </script>
<!-- Generated theme CSS with @font-face declarations --> <!-- 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 @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 cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
search_query search_results search_open categories shipping_estimate 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 """ @doc """
Extracts the assigns relevant to `shop_layout` from a full assigns map. 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 :active_page, :string, required: true
attr :error_page, :boolean, default: false attr :error_page, :boolean, default: false
attr :is_admin, :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_query, :string, default: ""
attr :search_results, :list, default: [] attr :search_results, :list, default: []
attr :search_open, :boolean, default: false attr :search_open, :boolean, default: false
@ -117,6 +120,9 @@ defmodule BerrypodWeb.ShopComponents.Layout do
mode={@mode} mode={@mode}
cart_count={@cart_count} cart_count={@cart_count}
is_admin={@is_admin} is_admin={@is_admin}
editing={@editing}
editor_current_path={@editor_current_path}
editor_sidebar_open={@editor_sidebar_open}
/> />
{render_slot(@inner_block)} {render_slot(@inner_block)}
@ -685,6 +691,9 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :mode, :atom, default: :live attr :mode, :atom, default: :live
attr :cart_count, :integer, default: 0 attr :cart_count, :integer, default: 0
attr :is_admin, :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
def shop_header(assigns) do def shop_header(assigns) do
~H""" ~H"""
@ -729,6 +738,23 @@ defmodule BerrypodWeb.ShopComponents.Layout do
</nav> </nav>
<div class="shop-actions"> <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 <.link
:if={@is_admin} :if={@is_admin}
href="/admin" href="/admin"
@ -933,6 +959,25 @@ defmodule BerrypodWeb.ShopComponents.Layout do
""" """
end 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 defp open_cart_drawer_js do
Phoenix.LiveView.JS.push("open_cart_drawer") Phoenix.LiveView.JS.push("open_cart_drawer")
end end

View File

@ -2,10 +2,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view use BerrypodWeb, :live_view
alias Berrypod.{Media, Pages} alias Berrypod.{Media, Pages}
alias Berrypod.Pages.{BlockTypes, Defaults} alias Berrypod.Pages.{BlockEditor, BlockTypes}
alias Berrypod.Products.ProductImage alias Berrypod.Products.ProductImage
alias Berrypod.Theme.{Fonts, PreviewData} alias Berrypod.Theme.{Fonts, PreviewData}
import BerrypodWeb.BlockEditorComponents
@impl true @impl true
def mount(%{"slug" => slug}, _session, socket) do def mount(%{"slug" => slug}, _session, socket) do
page = Pages.get_page(slug) page = Pages.get_page(slug)
@ -35,97 +37,125 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> assign(:header_image, Media.get_header())} |> assign(:header_image, Media.get_header())}
end end
# ── Block manipulation events ────────────────────────────────────
@impl true @impl true
def handle_event("move_up", %{"id" => block_id}, socket) do def handle_event("move_up", %{"id" => block_id}, socket) do
blocks = socket.assigns.blocks case BlockEditor.move_up(socket.assigns.blocks, block_id) do
idx = Enum.find_index(blocks, &(&1["id"] == block_id)) {:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
if idx && idx > 0 do :noop ->
block = Enum.at(blocks, idx) {:noreply, socket}
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}
end end
end end
def handle_event("move_down", %{"id" => block_id}, socket) do def handle_event("move_down", %{"id" => block_id}, socket) do
blocks = socket.assigns.blocks case BlockEditor.move_down(socket.assigns.blocks, block_id) do
idx = Enum.find_index(blocks, &(&1["id"] == block_id)) {:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
if idx && idx < length(blocks) - 1 do :noop ->
block = Enum.at(blocks, idx) {:noreply, socket}
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}
end end
end end
def handle_event("remove_block", %{"id" => block_id}, socket) do def handle_event("remove_block", %{"id" => block_id}, socket) do
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) {:ok, new_blocks, message} = BlockEditor.remove_block(socket.assigns.blocks, block_id)
block_name = block_display_name(block) {:noreply, apply_mutation(socket, new_blocks, message)}
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")}
end end
def handle_event("duplicate_block", %{"id" => block_id}, socket) do def handle_event("duplicate_block", %{"id" => block_id}, socket) do
blocks = socket.assigns.blocks case BlockEditor.duplicate_block(socket.assigns.blocks, block_id) do
idx = Enum.find_index(blocks, &(&1["id"] == block_id)) {:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
if idx do :noop ->
original = Enum.at(blocks, idx) {:noreply, socket}
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}
end end
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 def handle_event("toggle_expand", %{"id" => block_id}, socket) do
expanded = socket.assigns.expanded expanded = socket.assigns.expanded
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) 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} = {new_expanded, action} =
if MapSet.member?(expanded, block_id) do 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}")} |> assign(:live_region_message, "#{block_name} settings #{action}")}
end 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 def handle_event("show_picker", _params, socket) do
{:noreply, {:noreply,
socket socket
@ -271,33 +185,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
{:noreply, assign(socket, :picker_filter, value)} {:noreply, assign(socket, :picker_filter, value)}
end end
def handle_event("add_block", %{"type" => type}, socket) do def handle_event("toggle_preview", _params, socket) do
block_def = BlockTypes.get(type) {:noreply, assign(socket, :show_preview, !socket.assigns.show_preview)}
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
end end
# ── Page actions ─────────────────────────────────────────────────
def handle_event("save", _params, socket) do def handle_event("save", _params, socket) do
%{slug: slug, blocks: blocks, page_data: page_data} = socket.assigns %{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")} |> put_flash(:info, "Page reset to defaults")}
end end
def handle_event("toggle_preview", _params, socket) do # ── Render ───────────────────────────────────────────────────────
{:noreply, assign(socket, :show_preview, !socket.assigns.show_preview)}
end
@impl true @impl true
def render(assigns) do def render(assigns) do
@ -439,7 +330,6 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
# ── Preview ─────────────────────────────────────────────────────── # ── Preview ───────────────────────────────────────────────────────
defp preview_pane(assigns) do 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} page = %{slug: assigns.slug, title: assigns.page_data.title, blocks: assigns.blocks}
preview = preview =
@ -554,427 +444,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
end end
# ── Block card ───────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────
defp block_card(assigns) do defp apply_mutation(socket, new_blocks, message) do
block_type = BlockTypes.get(assigns.block["type"]) socket
has_settings = has_settings?(assigns.block) |> assign(:blocks, new_blocks)
expanded = MapSet.member?(assigns.expanded, assigns.block["id"]) |> assign(:dirty, true)
|> assign(:live_region_message, message)
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"] || %{})
end end
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, router: BerrypodWeb.Router,
statics: BerrypodWeb.static_paths() statics: BerrypodWeb.static_paths()
import BerrypodWeb.BlockEditorComponents
import BerrypodWeb.CoreComponents, only: [icon: 1]
alias Berrypod.Cart alias Berrypod.Cart
# ── Public API ────────────────────────────────────────────────── # ── Public API ──────────────────────────────────────────────────
@ -24,8 +27,19 @@ defmodule BerrypodWeb.PageRenderer do
Expects `@page` (with `:slug` and `:blocks`) plus all the standard Expects `@page` (with `:slug` and `:blocks`) plus all the standard
layout assigns (theme_settings, cart_items, etc.). 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 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)) assigns = assign(assigns, :block_assigns, block_assigns(assigns))
~H""" ~H"""
@ -43,6 +57,122 @@ defmodule BerrypodWeb.PageRenderer do
""" """
end 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 ────────────────────────────────────────────── # ── Block dispatch ──────────────────────────────────────────────
defp render_block(%{block: %{"type" => "hero"}} = assigns) do 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 defp render_block(%{block: %{"type" => "content_body"}} = assigns) do
settings = assigns.block["settings"] || %{} settings = assigns.block["settings"] || %{}
content = settings["content"] || ""
assigns = assigns =
assigns assigns
|> assign(:image_src, settings["image_src"]) |> assign(:image_src, settings["image_src"])
|> assign(:image_alt, settings["image_alt"] || "") |> assign(:image_alt, settings["image_alt"] || "")
|> assign(:content, content)
~H""" ~H"""
<div class="content-body"> <div class="content-body">
@ -449,7 +581,17 @@ defmodule BerrypodWeb.PageRenderer do
</div> </div>
<% end %> <% 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> </div>
""" """
end end

View File

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

View File

@ -0,0 +1,187 @@
defmodule Berrypod.Pages.BlockEditorTest do
use ExUnit.Case, async: true
alias Berrypod.Pages.BlockEditor
defp make_block(id, type, settings \\ %{}) do
%{"id" => id, "type" => type, "settings" => settings}
end
defp three_blocks do
[
make_block("a", "hero", %{"title" => "Welcome"}),
make_block("b", "featured_products"),
make_block("c", "image_text", %{"title" => "About us"})
]
end
describe "move_up/2" do
test "moves a block up one position" do
{:ok, blocks, msg} = BlockEditor.move_up(three_blocks(), "b")
assert [%{"id" => "b"}, %{"id" => "a"}, %{"id" => "c"}] = blocks
assert msg =~ "moved to position"
end
test "returns :noop for the first block" do
assert :noop = BlockEditor.move_up(three_blocks(), "a")
end
test "returns :noop for unknown id" do
assert :noop = BlockEditor.move_up(three_blocks(), "zzz")
end
end
describe "move_down/2" do
test "moves a block down one position" do
{:ok, blocks, msg} = BlockEditor.move_down(three_blocks(), "b")
assert [%{"id" => "a"}, %{"id" => "c"}, %{"id" => "b"}] = blocks
assert msg =~ "moved to position"
end
test "returns :noop for the last block" do
assert :noop = BlockEditor.move_down(three_blocks(), "c")
end
end
describe "remove_block/2" do
test "removes the specified block" do
{:ok, blocks, msg} = BlockEditor.remove_block(three_blocks(), "b")
assert length(blocks) == 2
refute Enum.any?(blocks, &(&1["id"] == "b"))
assert msg =~ "removed"
end
end
describe "duplicate_block/2" do
test "inserts a copy after the original" do
{:ok, blocks, msg} = BlockEditor.duplicate_block(three_blocks(), "a")
assert length(blocks) == 4
assert Enum.at(blocks, 0)["id"] == "a"
# Copy is at index 1 with a new ID
copy = Enum.at(blocks, 1)
assert copy["type"] == "hero"
assert copy["id"] != "a"
assert copy["settings"] == %{"title" => "Welcome"}
assert msg =~ "duplicated"
end
test "returns :noop for unknown id" do
assert :noop = BlockEditor.duplicate_block(three_blocks(), "zzz")
end
end
describe "add_block/2" do
test "appends a new block of the given type" do
{:ok, blocks, msg} = BlockEditor.add_block(three_blocks(), "hero")
assert length(blocks) == 4
new = List.last(blocks)
assert new["type"] == "hero"
assert is_binary(new["id"])
assert msg =~ "added"
end
test "returns :noop for unknown block type" do
assert :noop = BlockEditor.add_block(three_blocks(), "nonexistent_block_type")
end
end
describe "update_settings/3" do
test "merges new settings into the block" do
{:ok, blocks} =
BlockEditor.update_settings(three_blocks(), "a", %{"title" => "New title"})
hero = Enum.find(blocks, &(&1["id"] == "a"))
assert hero["settings"]["title"] == "New title"
end
test "returns :noop for unknown block id" do
assert :noop = BlockEditor.update_settings(three_blocks(), "zzz", %{"title" => "Nope"})
end
end
describe "block_display_name/1" do
test "returns the block type name from registry" do
block = make_block("a", "hero")
assert BlockEditor.block_display_name(block) == "Hero banner"
end
test "returns the raw type for unknown types" do
block = make_block("a", "weird_thing")
assert BlockEditor.block_display_name(block) == "weird_thing"
end
test "handles nil" do
assert BlockEditor.block_display_name(nil) == "Block"
end
end
describe "has_settings?/1" do
test "returns true for blocks with settings schema" do
assert BlockEditor.has_settings?(make_block("a", "hero"))
end
test "returns false for blocks without settings" do
refute BlockEditor.has_settings?(make_block("a", "category_nav"))
end
end
describe "settings_with_defaults/1" do
test "fills in default values for missing settings" do
block = make_block("a", "hero", %{})
result = BlockEditor.settings_with_defaults(block)
# Hero has a title field with a default
assert is_binary(result["title"])
end
test "preserves existing settings" do
block = make_block("a", "hero", %{"title" => "Custom"})
result = BlockEditor.settings_with_defaults(block)
assert result["title"] == "Custom"
end
end
describe "coerce_settings/2" do
test "passes through string values" do
schema = [%{key: "title", type: :text, default: ""}]
result = BlockEditor.coerce_settings(%{"title" => "Hello"}, schema)
assert result["title"] == "Hello"
end
test "parses number fields" do
schema = [%{key: "columns", type: :number, default: 4}]
result = BlockEditor.coerce_settings(%{"columns" => "3"}, schema)
assert result["columns"] == 3
end
test "falls back to default for invalid numbers" do
schema = [%{key: "columns", type: :number, default: 4}]
result = BlockEditor.coerce_settings(%{"columns" => "abc"}, schema)
assert result["columns"] == 4
end
end
describe "parse_number/2" do
test "parses valid integer strings" do
assert BlockEditor.parse_number("42", 0) == 42
end
test "returns integer values as-is" do
assert BlockEditor.parse_number(7, 0) == 7
end
test "returns default for non-numeric strings" do
assert BlockEditor.parse_number("nope", 5) == 5
end
test "returns default for nil" do
assert BlockEditor.parse_number(nil, 10) == 10
end
end
end

View File

@ -0,0 +1,231 @@
defmodule BerrypodWeb.PageEditorHookTest do
use BerrypodWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
import Berrypod.ProductsFixtures
alias Berrypod.Pages
alias Berrypod.Pages.PageCache
setup do
PageCache.invalidate_all()
user = user_fixture()
{:ok, _} = Berrypod.Settings.set_site_live(true)
provider_conn = provider_connection_fixture()
product =
product_fixture(%{
provider_connection: provider_conn,
title: "Test Product",
category: "Test Category"
})
product_variant_fixture(%{product: product, title: "Standard", price: 1999})
Berrypod.Products.recompute_cached_fields(product)
%{user: user, product: product}
end
describe "non-admin cannot access edit mode" do
test "visiting with ?edit=true shows normal page, no sidebar", %{conn: conn} do
{:ok, _view, html} = live(conn, "/?edit=true")
refute html =~ "page-editor-sidebar"
refute html =~ "page-editor-live"
end
end
describe "edit button visibility" do
test "admin sees edit pencil in shop header", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, "/")
assert html =~ "Edit page"
end
test "non-admin does not see edit pencil", %{conn: conn} do
{:ok, _view, html} = live(conn, "/")
refute html =~ "Edit page"
end
end
describe "entering and exiting edit mode" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "admin enters edit mode with ?edit=true", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
assert has_element?(view, ".page-editor-sidebar")
assert has_element?(view, ".page-editor-content")
assert has_element?(view, ".block-card")
end
test "sidebar shows the page title", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
assert has_element?(view, ".page-editor-sidebar-title", "Home page")
end
test "done button exits edit mode", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
# editor_done uses push_navigate, which causes a redirect
{:error, {:live_redirect, %{to: "/"}}} =
view |> element("button[phx-click='editor_done']") |> render_click()
end
test "toggle sidebar hides and shows via header pencil", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
# Sidebar starts open, pencil button in header is hidden
assert has_element?(view, "[data-sidebar-open='true']")
refute has_element?(
view,
"button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']"
)
# Close the sidebar via the X button
view
|> element("button[phx-click='editor_toggle_sidebar'][aria-label='Close sidebar']")
|> render_click()
assert has_element?(view, "[data-sidebar-open='false']")
# Pencil button appears in header to re-open
assert has_element?(
view,
"button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']"
)
# Re-open via pencil in header
view
|> element("button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']")
|> render_click()
assert has_element?(view, "[data-sidebar-open='true']")
end
test "clicking backdrop hides the sidebar", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
# Backdrop present when sidebar is open
assert has_element?(view, ".page-editor-backdrop")
# Click backdrop to dismiss
view |> element(".page-editor-backdrop") |> render_click()
assert has_element?(view, "[data-sidebar-open='false']")
# Backdrop gone when sidebar is hidden
refute has_element?(view, ".page-editor-backdrop")
end
end
describe "block manipulation in edit mode" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "move block down reorders the list", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
# Home page default: hero is first block
first_card = view |> element(".block-card:first-child")
first_html = render(first_card)
assert first_html =~ "Hero"
# Get the hero block's ID and move it down
blocks = Pages.get_page("home").blocks
hero_id = List.first(blocks)["id"]
view
|> element("button[phx-click='editor_move_down'][phx-value-id='#{hero_id}']")
|> render_click()
# After move, hero is no longer the first card
updated_first = view |> element(".block-card:first-child") |> render()
refute updated_first =~ "Hero"
end
test "dirty indicator appears after changes", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
refute has_element?(view, ".admin-badge-warning", "Unsaved changes")
# Move a block to trigger dirty state
blocks = Pages.get_page("home").blocks
second_id = Enum.at(blocks, 1)["id"]
view
|> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
|> render_click()
assert has_element?(view, ".admin-badge-warning", "Unsaved changes")
end
end
describe "save and reset" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "save persists block changes", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
# Move a block to make changes
blocks = Pages.get_page("home").blocks
original_first_type = List.first(blocks)["type"]
second_id = Enum.at(blocks, 1)["id"]
view
|> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
|> render_click()
# Save
view |> element("button[phx-click='editor_save']") |> render_click()
assert has_element?(view, "#shop-flash-info", "Page saved")
# Verify persistence
updated = Pages.get_page("home")
refute List.first(updated.blocks)["type"] == original_first_type
end
test "reset restores default blocks", %{conn: conn} do
# First, save a modified page
original = Pages.get_page("home")
reordered = Enum.reverse(original.blocks)
Pages.save_page("home", %{title: original.title, blocks: reordered})
PageCache.invalidate_all()
{:ok, view, _html} = live(conn, "/?edit=true")
# Reset
view |> element("button[phx-click='editor_reset_defaults']") |> render_click()
assert has_element?(view, "#shop-flash-info", "Page reset to defaults")
# Verify the blocks are back to defaults
reset_page = Pages.get_page("home")
assert List.first(reset_page.blocks)["type"] == List.first(original.blocks)["type"]
end
end
describe "content pages (deferred init)" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "editing works on about page via deferred init", %{conn: conn} do
{:ok, view, _html} = live(conn, "/about?edit=true")
assert has_element?(view, ".page-editor-sidebar")
assert has_element?(view, ".page-editor-sidebar-title", "About")
assert has_element?(view, ".block-card")
end
end
end