add live page editor sidebar with collapsible UI
All checks were successful
deploy / deploy (push) Successful in 6m49s
All checks were successful
deploy / deploy (push) Successful in 6m49s
Admins can now edit pages directly on the live shop by clicking the pencil icon in the header. A sidebar slides in with block management controls (add, remove, reorder, edit settings, save, reset, done). Key features: - PageEditorHook on_mount with handle_params/event/info hooks - BlockEditor pure functions extracted from admin editor - Shared BlockEditorComponents with event_prefix namespacing - Collapsible sidebar: X closes it, header pencil reopens it - Backdrop overlay dismisses sidebar on tap - Conditional admin.css loading for logged-in users - content_body block now portable (textarea setting + rich text fallback) 13 integration tests, 26 unit tests, 1370 total passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b340c24aa1
commit
a039c8d53c
@ -183,6 +183,11 @@
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-btn-xs {
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.admin-btn-icon {
|
||||
padding: 0.5rem;
|
||||
aspect-ratio: 1;
|
||||
@ -469,6 +474,11 @@
|
||||
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 ── */
|
||||
|
||||
.admin-dropdown {
|
||||
@ -1465,4 +1475,95 @@
|
||||
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 */
|
||||
|
||||
255
lib/berrypod/pages/block_editor.ex
Normal file
255
lib/berrypod/pages/block_editor.ex
Normal file
@ -0,0 +1,255 @@
|
||||
defmodule Berrypod.Pages.BlockEditor do
|
||||
@moduledoc """
|
||||
Pure functions for block list manipulation.
|
||||
|
||||
Used by both the admin page editor and the live page editor sidebar.
|
||||
No side effects — takes a block list in, returns a block list out.
|
||||
"""
|
||||
|
||||
alias Berrypod.Pages.{BlockTypes, Defaults}
|
||||
|
||||
# ── Block operations ─────────────────────────────────────────────
|
||||
|
||||
def move_up(blocks, block_id) do
|
||||
idx = Enum.find_index(blocks, &(&1["id"] == block_id))
|
||||
|
||||
if idx && idx > 0 do
|
||||
block = Enum.at(blocks, idx)
|
||||
name = block_display_name(block)
|
||||
|
||||
new_blocks =
|
||||
blocks
|
||||
|> List.delete_at(idx)
|
||||
|> List.insert_at(idx - 1, block)
|
||||
|
||||
{:ok, new_blocks, "#{name} moved to position #{idx}"}
|
||||
else
|
||||
:noop
|
||||
end
|
||||
end
|
||||
|
||||
def move_down(blocks, block_id) do
|
||||
idx = Enum.find_index(blocks, &(&1["id"] == block_id))
|
||||
|
||||
if idx && idx < length(blocks) - 1 do
|
||||
block = Enum.at(blocks, idx)
|
||||
name = block_display_name(block)
|
||||
|
||||
new_blocks =
|
||||
blocks
|
||||
|> List.delete_at(idx)
|
||||
|> List.insert_at(idx + 1, block)
|
||||
|
||||
{:ok, new_blocks, "#{name} moved to position #{idx + 2}"}
|
||||
else
|
||||
:noop
|
||||
end
|
||||
end
|
||||
|
||||
def remove_block(blocks, block_id) do
|
||||
block = Enum.find(blocks, &(&1["id"] == block_id))
|
||||
name = block_display_name(block)
|
||||
new_blocks = Enum.reject(blocks, &(&1["id"] == block_id))
|
||||
{:ok, new_blocks, "#{name} removed"}
|
||||
end
|
||||
|
||||
def duplicate_block(blocks, block_id) do
|
||||
idx = Enum.find_index(blocks, &(&1["id"] == block_id))
|
||||
|
||||
if idx do
|
||||
original = Enum.at(blocks, idx)
|
||||
|
||||
copy = %{
|
||||
"id" => Defaults.generate_block_id(),
|
||||
"type" => original["type"],
|
||||
"settings" => original["settings"] || %{}
|
||||
}
|
||||
|
||||
name = block_display_name(original)
|
||||
new_blocks = List.insert_at(blocks, idx + 1, copy)
|
||||
{:ok, new_blocks, "#{name} duplicated"}
|
||||
else
|
||||
:noop
|
||||
end
|
||||
end
|
||||
|
||||
def add_block(blocks, type) do
|
||||
block_def = BlockTypes.get(type)
|
||||
|
||||
if block_def do
|
||||
default_settings =
|
||||
block_def
|
||||
|> Map.get(:settings_schema, [])
|
||||
|> Enum.into(%{}, fn field -> {field.key, field.default} end)
|
||||
|
||||
new_block = %{
|
||||
"id" => Defaults.generate_block_id(),
|
||||
"type" => type,
|
||||
"settings" => default_settings
|
||||
}
|
||||
|
||||
{:ok, blocks ++ [new_block], "#{block_def.name} added"}
|
||||
else
|
||||
:noop
|
||||
end
|
||||
end
|
||||
|
||||
def update_settings(blocks, block_id, new_settings) do
|
||||
block = Enum.find(blocks, &(&1["id"] == block_id))
|
||||
|
||||
if block do
|
||||
schema =
|
||||
case BlockTypes.get(block["type"]) do
|
||||
%{settings_schema: s} -> s
|
||||
_ -> []
|
||||
end
|
||||
|
||||
coerced = coerce_settings(new_settings, schema)
|
||||
|
||||
new_blocks =
|
||||
Enum.map(blocks, fn b ->
|
||||
if b["id"] == block_id do
|
||||
Map.put(b, "settings", Map.merge(b["settings"] || %{}, coerced))
|
||||
else
|
||||
b
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, new_blocks}
|
||||
else
|
||||
:noop
|
||||
end
|
||||
end
|
||||
|
||||
# ── Repeater operations ──────────────────────────────────────────
|
||||
|
||||
def repeater_add(blocks, block_id, field_key) do
|
||||
block = Enum.find(blocks, &(&1["id"] == block_id))
|
||||
|
||||
if block do
|
||||
schema = BlockTypes.get(block["type"])
|
||||
field = Enum.find(schema.settings_schema, &(&1.key == field_key))
|
||||
empty_item = Map.new(field.item_schema, fn f -> {f.key, f.default} end)
|
||||
|
||||
items = (block["settings"] || %{})[field_key] || []
|
||||
new_items = items ++ [empty_item]
|
||||
new_blocks = update_block_field(blocks, block_id, field_key, new_items)
|
||||
{:ok, new_blocks, "Item added"}
|
||||
else
|
||||
:noop
|
||||
end
|
||||
end
|
||||
|
||||
def repeater_remove(blocks, block_id, field_key, index) do
|
||||
block = Enum.find(blocks, &(&1["id"] == block_id))
|
||||
|
||||
if block do
|
||||
items = (block["settings"] || %{})[field_key] || []
|
||||
new_items = List.delete_at(items, index)
|
||||
new_blocks = update_block_field(blocks, block_id, field_key, new_items)
|
||||
{:ok, new_blocks, "Item removed"}
|
||||
else
|
||||
:noop
|
||||
end
|
||||
end
|
||||
|
||||
def repeater_move(blocks, block_id, field_key, index, direction) do
|
||||
block = Enum.find(blocks, &(&1["id"] == block_id))
|
||||
|
||||
if block do
|
||||
items = (block["settings"] || %{})[field_key] || []
|
||||
target = if direction == "up", do: index - 1, else: index + 1
|
||||
|
||||
if target >= 0 and target < length(items) do
|
||||
item = Enum.at(items, index)
|
||||
|
||||
new_items =
|
||||
items
|
||||
|> List.delete_at(index)
|
||||
|> List.insert_at(target, item)
|
||||
|
||||
new_blocks = update_block_field(blocks, block_id, field_key, new_items)
|
||||
{:ok, new_blocks, "Item moved #{direction}"}
|
||||
else
|
||||
:noop
|
||||
end
|
||||
else
|
||||
:noop
|
||||
end
|
||||
end
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def block_display_name(nil), do: "Block"
|
||||
|
||||
def block_display_name(block) do
|
||||
case BlockTypes.get(block["type"]) do
|
||||
%{name: name} -> name
|
||||
_ -> block["type"]
|
||||
end
|
||||
end
|
||||
|
||||
def has_settings?(block) do
|
||||
case BlockTypes.get(block["type"]) do
|
||||
%{settings_schema: [_ | _]} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
def settings_with_defaults(block) do
|
||||
schema =
|
||||
case BlockTypes.get(block["type"]) do
|
||||
%{settings_schema: s} -> s
|
||||
_ -> []
|
||||
end
|
||||
|
||||
defaults = Map.new(schema, fn field -> {field.key, field.default} end)
|
||||
Map.merge(defaults, block["settings"] || %{})
|
||||
end
|
||||
|
||||
def coerce_settings(params, schema) do
|
||||
type_map = Map.new(schema, fn field -> {field.key, field} end)
|
||||
|
||||
Map.new(params, fn {key, value} ->
|
||||
case type_map[key] do
|
||||
%{type: :number, default: default} ->
|
||||
{key, parse_number(value, default)}
|
||||
|
||||
%{type: :repeater} ->
|
||||
{key, indexed_map_to_list(value)}
|
||||
|
||||
_ ->
|
||||
{key, value}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def parse_number(value, default) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{n, ""} -> n
|
||||
_ -> default
|
||||
end
|
||||
end
|
||||
|
||||
def parse_number(value, _default) when is_integer(value), do: value
|
||||
def parse_number(_value, default), do: default
|
||||
|
||||
defp update_block_field(blocks, block_id, field_key, new_value) do
|
||||
Enum.map(blocks, fn b ->
|
||||
if b["id"] == block_id do
|
||||
settings = Map.put(b["settings"] || %{}, field_key, new_value)
|
||||
Map.put(b, "settings", settings)
|
||||
else
|
||||
b
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp indexed_map_to_list(map) when is_map(map) do
|
||||
map
|
||||
|> Enum.sort_by(fn {k, _v} -> String.to_integer(k) end)
|
||||
|> Enum.map(fn {_k, v} -> v end)
|
||||
end
|
||||
|
||||
defp indexed_map_to_list(value), do: value
|
||||
end
|
||||
@ -233,8 +233,9 @@ defmodule Berrypod.Pages.BlockTypes do
|
||||
"content_body" => %{
|
||||
name: "Page content",
|
||||
icon: "hero-document-text",
|
||||
allowed_on: ["about", "delivery", "privacy", "terms"],
|
||||
allowed_on: :all,
|
||||
settings_schema: [
|
||||
%SettingsField{key: "content", label: "Content", type: :textarea, default: ""},
|
||||
%SettingsField{key: "image_src", label: "Image", type: :text, default: ""},
|
||||
%SettingsField{key: "image_alt", label: "Image alt text", type: :text, default: ""}
|
||||
]
|
||||
|
||||
405
lib/berrypod_web/components/block_editor_components.ex
Normal file
405
lib/berrypod_web/components/block_editor_components.ex
Normal 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
|
||||
@ -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 -->
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
: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
|
||||
: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
|
||||
: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
|
||||
|
||||
360
lib/berrypod_web/page_editor_hook.ex
Normal file
360
lib/berrypod_web/page_editor_hook.ex
Normal 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
|
||||
@ -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 %>
|
||||
|
||||
<%= 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
|
||||
|
||||
@ -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
|
||||
|
||||
187
test/berrypod/pages/block_editor_test.exs
Normal file
187
test/berrypod/pages/block_editor_test.exs
Normal 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
|
||||
231
test/berrypod_web/page_editor_hook_test.exs
Normal file
231
test/berrypod_web/page_editor_hook_test.exs
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user