All checks were successful
deploy / deploy (push) Successful in 1m27s
auth pages (login, registration, confirmation, recover) now use setup-page/setup-header/admin-btn-block. theme toggle indicator gets proper CSS. cleaned up dead h-full, size-3.5, ml-2 classes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
645 lines
21 KiB
Elixir
645 lines
21 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}
|
|
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
|