Some checks failed
deploy / deploy (push) Has been cancelled
Legal pages (privacy, delivery, terms) now auto-populate content from shop settings on mount, show auto-generated vs customised badges, and have a regenerate button. Theme editor gains alt text fields for logo, header, and icon images. Image picker in page builder now has an upload button and alt text warning badges. Clearing unused image references shows an orphan info flash. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
564 lines
18 KiB
Elixir
564 lines
18 KiB
Elixir
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: :image}} = assigns) do
|
|
image_id = assigns.value
|
|
image = if is_binary(image_id) && image_id != "", do: Berrypod.Media.get_image(image_id)
|
|
assigns = assign(assigns, :image, image)
|
|
|
|
~H"""
|
|
<div class="admin-fieldset">
|
|
<span class="admin-label">{@field.label}</span>
|
|
<div class="image-field">
|
|
<%= if @image do %>
|
|
<div class="image-field-preview">
|
|
<%= if @image.is_svg do %>
|
|
<div class="image-field-svg">
|
|
<.icon name="hero-code-bracket" class="size-6" />
|
|
</div>
|
|
<% else %>
|
|
<img
|
|
src={"/image_cache/#{@image.id}-thumb.jpg"}
|
|
alt={@image.alt || @image.filename}
|
|
class="image-field-thumb"
|
|
/>
|
|
<% end %>
|
|
<div class="image-field-info">
|
|
<span class="image-field-filename">{@image.filename}</span>
|
|
<span :if={@image.alt} class="image-field-alt">{@image.alt}</span>
|
|
</div>
|
|
</div>
|
|
<div class="image-field-actions">
|
|
<button
|
|
type="button"
|
|
phx-click={"#{@event_prefix}show_image_picker"}
|
|
phx-value-block-id={@block_id}
|
|
phx-value-field={@field.key}
|
|
class="admin-btn admin-btn-outline admin-btn-xs"
|
|
>
|
|
Change
|
|
</button>
|
|
<button
|
|
type="button"
|
|
phx-click={"#{@event_prefix}clear_image"}
|
|
phx-value-block-id={@block_id}
|
|
phx-value-field={@field.key}
|
|
class="admin-btn admin-btn-ghost admin-btn-xs image-field-remove-btn"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
<% else %>
|
|
<button
|
|
type="button"
|
|
phx-click={"#{@event_prefix}show_image_picker"}
|
|
phx-value-block-id={@block_id}
|
|
phx-value-field={@field.key}
|
|
class="admin-btn admin-btn-outline admin-btn-sm image-field-choose-btn"
|
|
>
|
|
<.icon name="hero-photo" class="size-4" /> Choose image
|
|
</button>
|
|
<% end %>
|
|
</div>
|
|
</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
|
|
|
|
# ── Image picker ──────────────────────────────────────────────
|
|
|
|
attr :images, :list, required: true
|
|
attr :search, :string, required: true
|
|
attr :event_prefix, :string, default: ""
|
|
attr :upload, :any, default: nil
|
|
|
|
def image_picker(assigns) do
|
|
search = String.downcase(assigns.search)
|
|
|
|
filtered =
|
|
if search == "" do
|
|
assigns.images
|
|
else
|
|
Enum.filter(assigns.images, fn img ->
|
|
String.contains?(String.downcase(img.filename || ""), search) or
|
|
String.contains?(String.downcase(img.alt || ""), search)
|
|
end)
|
|
end
|
|
|
|
assigns = assign(assigns, :filtered_images, filtered)
|
|
|
|
~H"""
|
|
<div class="image-picker-overlay">
|
|
<div class="image-picker" phx-click-away={"#{@event_prefix}hide_image_picker"}>
|
|
<div class="image-picker-header">
|
|
<h3>Choose image</h3>
|
|
<button
|
|
phx-click={"#{@event_prefix}hide_image_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="Search images..."
|
|
value={@search}
|
|
phx-keyup={"#{@event_prefix}image_picker_search"}
|
|
phx-key=""
|
|
class="admin-input image-picker-search"
|
|
autofocus
|
|
/>
|
|
|
|
<div :if={@upload} class="image-picker-upload">
|
|
<form phx-change="noop" phx-submit="noop">
|
|
<label class="image-picker-upload-label">
|
|
<.icon name="hero-arrow-up-tray" class="size-4" />
|
|
<span>Upload new image</span>
|
|
<.live_file_input upload={@upload} class="hidden" />
|
|
</label>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="image-picker-grid">
|
|
<button
|
|
:for={image <- @filtered_images}
|
|
type="button"
|
|
phx-click={"#{@event_prefix}pick_image"}
|
|
phx-value-image-id={image.id}
|
|
class="image-picker-item"
|
|
>
|
|
<%= if image.is_svg do %>
|
|
<div class="image-picker-item-svg">
|
|
<.icon name="hero-code-bracket" class="size-6" />
|
|
</div>
|
|
<% else %>
|
|
<img
|
|
src={"/image_cache/#{image.id}-thumb.jpg"}
|
|
alt={image.alt || image.filename}
|
|
class="image-picker-item-thumb"
|
|
loading="lazy"
|
|
/>
|
|
<% end %>
|
|
<span class="image-picker-item-name">{image.filename}</span>
|
|
<span
|
|
:if={!image.alt || image.alt == ""}
|
|
class="image-picker-item-no-alt"
|
|
title="Missing alt text"
|
|
>
|
|
<.icon name="hero-exclamation-triangle-mini" class="size-3" />
|
|
</span>
|
|
</button>
|
|
|
|
<p :if={@filtered_images == []} class="image-picker-empty">
|
|
No images found.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
end
|