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}
alias BerrypodWeb.BlockThumbnails
# ── 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"])
preview = block_preview(assigns.block, block_type)
assigns =
assigns
|> assign(:block_type, block_type)
|> assign(:has_settings, has_settings)
|> assign(:is_expanded, expanded)
|> assign(:preview, preview)
~H"""
<.block_settings_form
:if={@is_expanded}
block={@block}
schema={@block_type.settings_schema}
hint={@block_type[:hint]}
event_prefix={@event_prefix}
/>
"""
end
# ── Settings form ──────────────────────────────────────────────
attr :block, :map, required: true
attr :schema, :list, required: true
attr :hint, :string, default: nil
attr :event_prefix, :string, default: ""
def block_settings_form(assigns) do
settings = BlockEditor.settings_with_defaults(assigns.block)
assigns = assign(assigns, :settings, settings)
~H"""
<.icon name="hero-information-circle-mini" class="size-4" /> {@hint}
"""
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"""
{@field.label}
{Phoenix.HTML.Form.options_for_select(@field.options, to_string(@value))}
"""
end
def block_field(%{field: %{type: :textarea}} = assigns) do
~H"""
{@field.label}
"""
end
def block_field(%{field: %{type: :number}} = assigns) do
~H"""
{@field.label}
"""
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"""
{@field.label}
<%= if @image do %>
<%= if @image.is_svg do %>
<.icon name="hero-code-bracket" class="size-6" />
<% else %>
<% end %>
{@image.filename}
{@image.alt}
Change
Remove
<% else %>
<.icon name="hero-photo" class="size-4" /> Choose image
<% end %>
"""
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"""
{@field.label}
Item {idx + 1}: {item["label"] || item[:label] || "New item"}
<.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}
/>
<.icon name="hero-chevron-up-mini" class="size-4" />
<.icon name="hero-chevron-down-mini" class="size-4" />
<.icon name="hero-x-mark-mini" class="size-4" />
<.icon name="hero-plus-mini" class="size-4" /> Add item
"""
end
def block_field(assigns) do
~H"""
{@field.label}
"""
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"""
{@sub_field.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) or
String.contains?(String.downcase(Map.get(def, :description, "")), filter)
end)
|> Enum.sort_by(fn {_type, def} -> def.name end)
assigns = assign(assigns, :filtered_blocks, filtered)
~H"""
"""
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"""
<%= if image.is_svg do %>
<.icon name="hero-code-bracket" class="size-6" />
<% else %>
<% end %>
{image.filename}
<.icon name="hero-exclamation-triangle-mini" class="size-3" />
No images found.
"""
end
# ── Block preview ──────────────────────────────────────────────
# Extracts a short one-line preview from block settings for display in the card
defp block_preview(_block, nil), do: nil
defp block_preview(block, block_type) do
settings = block["settings"] || %{}
schema = block_type.settings_schema
# Try text/textarea fields first, then selects, then repeaters
find_text_preview(settings, schema) ||
find_select_preview(settings, schema) ||
find_repeater_preview(settings, schema)
end
defp find_text_preview(settings, schema) do
schema
|> Enum.filter(&(&1.type in [:text, :textarea]))
|> Enum.find_value(fn field ->
case settings[field.key] do
val when is_binary(val) and val != "" ->
truncate(val, 60)
_ ->
nil
end
end)
end
defp find_select_preview(settings, schema) do
schema
|> Enum.filter(&(&1.type == :select))
|> Enum.find_value(fn field ->
case settings[field.key] do
val when is_binary(val) and val != "" -> "#{field.label}: #{val}"
_ -> nil
end
end)
end
defp find_repeater_preview(settings, schema) do
schema
|> Enum.filter(&(&1.type == :repeater))
|> Enum.find_value(fn field ->
case settings[field.key] do
items when is_list(items) and items != [] ->
count = length(items)
"#{count} #{if count == 1, do: "item", else: "items"}"
_ ->
nil
end
end)
end
defp truncate(str, max) do
str = String.replace(str, ~r/\s+/, " ") |> String.trim()
if String.length(str) > max do
String.slice(str, 0, max) <> "..."
else
str
end
end
end