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;
|
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 */
|
||||||
|
|||||||
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" => %{
|
"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: ""}
|
||||||
]
|
]
|
||||||
|
|||||||
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;
|
@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 -->
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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,
|
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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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