berrypod/lib/berrypod_web/components/block_editor_components.ex

645 lines
21 KiB
Elixir
Raw Normal View History

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"""
<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>
<div class="block-card-info">
<span class="block-card-name">
{(@block_type && @block_type.name) || @block["type"]}
</span>
<span :if={@preview} class="block-card-preview">{@preview}</span>
</div>
<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}
hint={@block_type[:hint]}
event_prefix={@event_prefix}
/>
</div>
"""
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"""
<div class="block-card-settings" id={"block-settings-#{@block["id"]}"}>
<p :if={@hint} class="block-settings-hint">
<.icon name="hero-information-circle-mini" class="size-4" /> {@hint}
</p>
<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-4" />
</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-4" />
</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-4" />
</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-4" /> 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) 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"""
<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"
>
<BlockThumbnails.block_thumbnail type={type} />
<.icon name={def.icon} class="size-5" />
<span class="block-picker-item-name">{def.name}</span>
<span :if={def[:description]} class="block-picker-item-desc">
{def.description}
</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
# ── 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