379 lines
11 KiB
Elixir
379 lines
11 KiB
Elixir
|
|
defmodule BerrypodWeb.Admin.Pages.Editor do
|
||
|
|
use BerrypodWeb, :live_view
|
||
|
|
|
||
|
|
alias Berrypod.Pages
|
||
|
|
alias Berrypod.Pages.{BlockTypes, Defaults}
|
||
|
|
|
||
|
|
@impl true
|
||
|
|
def mount(%{"slug" => slug}, _session, socket) do
|
||
|
|
page = Pages.get_page(slug)
|
||
|
|
allowed_blocks = BlockTypes.allowed_for(slug)
|
||
|
|
|
||
|
|
{:ok,
|
||
|
|
socket
|
||
|
|
|> assign(:page_title, page.title)
|
||
|
|
|> assign(:slug, slug)
|
||
|
|
|> assign(:page_data, page)
|
||
|
|
|> assign(:blocks, page.blocks)
|
||
|
|
|> assign(:allowed_blocks, allowed_blocks)
|
||
|
|
|> assign(:dirty, false)
|
||
|
|
|> assign(:show_picker, false)
|
||
|
|
|> assign(:picker_filter, "")
|
||
|
|
|> assign(:live_region_message, nil)}
|
||
|
|
end
|
||
|
|
|
||
|
|
@impl true
|
||
|
|
def handle_event("move_up", %{"id" => block_id}, socket) do
|
||
|
|
blocks = socket.assigns.blocks
|
||
|
|
idx = Enum.find_index(blocks, &(&1["id"] == block_id))
|
||
|
|
|
||
|
|
if idx && idx > 0 do
|
||
|
|
block = Enum.at(blocks, idx)
|
||
|
|
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
|
||
|
|
|
||
|
|
def handle_event("move_down", %{"id" => block_id}, socket) do
|
||
|
|
blocks = socket.assigns.blocks
|
||
|
|
idx = Enum.find_index(blocks, &(&1["id"] == block_id))
|
||
|
|
|
||
|
|
if idx && idx < length(blocks) - 1 do
|
||
|
|
block = Enum.at(blocks, idx)
|
||
|
|
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
|
||
|
|
|
||
|
|
def handle_event("remove_block", %{"id" => block_id}, socket) do
|
||
|
|
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
|
||
|
|
block_name = block_display_name(block)
|
||
|
|
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
|
||
|
|
|
||
|
|
def handle_event("duplicate_block", %{"id" => block_id}, socket) do
|
||
|
|
blocks = socket.assigns.blocks
|
||
|
|
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"] || %{}
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
def handle_event("show_picker", _params, socket) do
|
||
|
|
{:noreply,
|
||
|
|
socket
|
||
|
|
|> assign(:show_picker, true)
|
||
|
|
|> assign(:picker_filter, "")}
|
||
|
|
end
|
||
|
|
|
||
|
|
def handle_event("hide_picker", _params, socket) do
|
||
|
|
{:noreply, assign(socket, :show_picker, false)}
|
||
|
|
end
|
||
|
|
|
||
|
|
def handle_event("filter_picker", %{"value" => value}, socket) do
|
||
|
|
{:noreply, assign(socket, :picker_filter, value)}
|
||
|
|
end
|
||
|
|
|
||
|
|
def handle_event("add_block", %{"type" => type}, socket) do
|
||
|
|
block_def = BlockTypes.get(type)
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
def handle_event("save", _params, socket) do
|
||
|
|
%{slug: slug, blocks: blocks, page_data: page_data} = socket.assigns
|
||
|
|
|
||
|
|
case Pages.save_page(slug, %{title: page_data.title, blocks: blocks}) do
|
||
|
|
{:ok, _page} ->
|
||
|
|
{:noreply,
|
||
|
|
socket
|
||
|
|
|> assign(:dirty, false)
|
||
|
|
|> put_flash(:info, "Page saved")}
|
||
|
|
|
||
|
|
{:error, _changeset} ->
|
||
|
|
{:noreply, put_flash(socket, :error, "Failed to save page")}
|
||
|
|
end
|
||
|
|
end
|
||
|
|
|
||
|
|
def handle_event("reset_defaults", _params, socket) do
|
||
|
|
slug = socket.assigns.slug
|
||
|
|
:ok = Pages.reset_page(slug)
|
||
|
|
page = Pages.get_page(slug)
|
||
|
|
|
||
|
|
{:noreply,
|
||
|
|
socket
|
||
|
|
|> assign(:blocks, page.blocks)
|
||
|
|
|> assign(:dirty, false)
|
||
|
|
|> put_flash(:info, "Page reset to defaults")}
|
||
|
|
end
|
||
|
|
|
||
|
|
@impl true
|
||
|
|
def render(assigns) do
|
||
|
|
~H"""
|
||
|
|
<div id="page-editor" phx-hook="DirtyGuard" data-dirty={to_string(@dirty)}>
|
||
|
|
<.link
|
||
|
|
navigate={~p"/admin/pages"}
|
||
|
|
class="text-sm font-normal text-base-content/60 hover:underline"
|
||
|
|
>
|
||
|
|
← Pages
|
||
|
|
</.link>
|
||
|
|
<.header>
|
||
|
|
{@page_data.title}
|
||
|
|
<:actions>
|
||
|
|
<button
|
||
|
|
phx-click="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 to defaults
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
phx-click="save"
|
||
|
|
class={["admin-btn admin-btn-sm admin-btn-primary", !@dirty && "opacity-50"]}
|
||
|
|
disabled={!@dirty}
|
||
|
|
>
|
||
|
|
Save
|
||
|
|
</button>
|
||
|
|
</:actions>
|
||
|
|
</.header>
|
||
|
|
|
||
|
|
<%!-- ARIA live region for screen reader announcements --%>
|
||
|
|
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||
|
|
{if @live_region_message, do: @live_region_message}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<%!-- Unsaved changes indicator --%>
|
||
|
|
<p :if={@dirty} class="admin-badge admin-badge-warning mt-4">
|
||
|
|
Unsaved changes
|
||
|
|
</p>
|
||
|
|
|
||
|
|
<%!-- Block list --%>
|
||
|
|
<div class="block-list" role="list" aria-label="Page blocks">
|
||
|
|
<.block_card
|
||
|
|
:for={{block, idx} <- Enum.with_index(@blocks)}
|
||
|
|
block={block}
|
||
|
|
idx={idx}
|
||
|
|
total={length(@blocks)}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<div :if={@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="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={@show_picker}
|
||
|
|
allowed_blocks={@allowed_blocks}
|
||
|
|
filter={@picker_filter}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
"""
|
||
|
|
end
|
||
|
|
|
||
|
|
defp block_card(assigns) do
|
||
|
|
block_type = BlockTypes.get(assigns.block["type"])
|
||
|
|
assigns = assign(assigns, :block_type, block_type)
|
||
|
|
|
||
|
|
~H"""
|
||
|
|
<div
|
||
|
|
class="block-card"
|
||
|
|
role="listitem"
|
||
|
|
aria-label={"#{@block_type && @block_type.name || @block["type"]}, position #{@idx + 1} of #{@total}"}
|
||
|
|
id={"block-#{@block["id"]}"}
|
||
|
|
>
|
||
|
|
<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
|
||
|
|
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>
|
||
|
|
"""
|
||
|
|
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
|
||
|
|
end
|