add legal page editor integration and media library polish
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>
This commit is contained in:
jamey
2026-02-28 17:55:02 +00:00
parent 3336b3aa26
commit 93ff66debc
9 changed files with 495 additions and 11 deletions

View File

@@ -471,6 +471,7 @@ defmodule BerrypodWeb.BlockEditorComponents do
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)
@@ -511,6 +512,16 @@ defmodule BerrypodWeb.BlockEditorComponents do
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}
@@ -532,6 +543,13 @@ defmodule BerrypodWeb.BlockEditorComponents do
/>
<% 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">

View File

@@ -1,7 +1,7 @@
defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view
alias Berrypod.{Media, Pages}
alias Berrypod.{LegalPages, Media, Pages}
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
alias Berrypod.Products.ProductImage
alias Berrypod.Theme.{Fonts, PreviewData}
@@ -41,7 +41,98 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> assign(:image_picker_field_key, nil)
|> assign(:image_picker_images, [])
|> assign(:image_picker_search, "")
|> assign(:is_custom_page, !Page.system_slug?(slug))}
|> assign(:is_custom_page, !Page.system_slug?(slug))
|> assign(:is_legal_page, LegalPages.legal_slug?(slug))
|> allow_upload(:image_picker_upload,
accept: ~w(.png .jpg .jpeg .webp .svg),
max_entries: 1,
max_file_size: 5_000_000,
auto_upload: true,
progress: &handle_image_picker_upload/3
)
|> assign_content_source(slug, page.blocks)}
end
defp handle_image_picker_upload(:image_picker_upload, entry, socket) do
if entry.done? do
consume_uploaded_entries(socket, :image_picker_upload, fn %{path: path}, entry ->
case Media.upload_from_entry(path, entry, "media") do
{:ok, image} -> {:ok, image}
{:error, _} = error -> error
end
end)
|> case do
[{:ok, image}] ->
block_id = socket.assigns.image_picker_block_id
field_key = socket.assigns.image_picker_field_key
case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{
field_key => image.id
}) do
{:ok, new_blocks} ->
{:noreply,
socket
|> apply_mutation(new_blocks, "Image uploaded and selected")
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)}
:noop ->
{:noreply, socket}
end
_ ->
{:noreply, put_flash(socket, :error, "Upload failed")}
end
else
{:noreply, socket}
end
end
# Determines whether legal page content is auto-generated or customised.
# For non-legal pages, content_source is nil (not shown).
# On mount, auto-populates empty legal pages with generated content.
defp assign_content_source(socket, slug, blocks) do
if LegalPages.legal_slug?(slug) do
current_content = get_content_body_text(blocks)
fresh_content = LegalPages.regenerate_legal_content(slug)
cond do
# Empty content — auto-populate on mount
current_content == "" ->
blocks = populate_content_body(blocks, fresh_content)
socket
|> assign(:blocks, blocks)
|> assign(:content_source, :auto)
# Content matches generated — it's auto
current_content == fresh_content ->
assign(socket, :content_source, :auto)
# Content differs — it's been customised
true ->
assign(socket, :content_source, :custom)
end
else
assign(socket, :content_source, nil)
end
end
defp populate_content_body(blocks, content) do
Enum.map(blocks, fn
%{"type" => "content_body", "settings" => settings} = block ->
%{block | "settings" => Map.put(settings || %{}, "content", content)}
block ->
block
end)
end
defp get_content_body_text(blocks) do
case Enum.find(blocks, &(&1["type"] == "content_body")) do
%{"settings" => %{"content" => content}} when is_binary(content) -> content
_ -> ""
end
end
# ── Block manipulation events ────────────────────────────────────
@@ -101,7 +192,17 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do
{:ok, new_blocks} ->
{:noreply, apply_mutation(socket, new_blocks, "Settings updated")}
socket = apply_mutation(socket, new_blocks, "Settings updated")
# Update content source badge if a legal page content block was edited
socket =
if socket.assigns.is_legal_page do
assign_content_source(socket, socket.assigns.slug, new_blocks)
else
socket
end
{:noreply, socket}
:noop ->
{:noreply, socket}
@@ -202,9 +303,30 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
%{"block-id" => block_id, "field" => field_key},
socket
) do
# Grab the old image_id before clearing
old_image_id =
case Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) do
%{"settings" => settings} -> settings[field_key]
_ -> nil
end
case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => ""}) do
{:ok, new_blocks} ->
{:noreply, apply_mutation(socket, new_blocks, "Image cleared")}
socket = apply_mutation(socket, new_blocks, "Image cleared")
socket =
if is_binary(old_image_id) && old_image_id != "" &&
Media.find_usages(old_image_id) == [] do
put_flash(
socket,
:info,
"Image removed — it's no longer used anywhere and can be deleted from the media library"
)
else
socket
end
{:noreply, socket}
:noop ->
{:noreply, socket}
@@ -267,6 +389,29 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
end
end
def handle_event("regenerate_legal", _params, socket) do
slug = socket.assigns.slug
content = LegalPages.regenerate_legal_content(slug)
case Enum.find(socket.assigns.blocks, &(&1["type"] == "content_body")) do
%{"id" => block_id} ->
case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{"content" => content}) do
{:ok, new_blocks} ->
{:noreply,
socket
|> apply_mutation(new_blocks, "Legal content regenerated")
|> assign(:content_source, :auto)
|> put_flash(:info, "Content regenerated from current settings")}
:noop ->
{:noreply, socket}
end
nil ->
{:noreply, put_flash(socket, :error, "No content block found on this page")}
end
end
def handle_event("reset_defaults", _params, socket) do
slug = socket.assigns.slug
:ok = Pages.reset_page(slug)
@@ -365,6 +510,14 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
>
<.icon name="hero-arrow-uturn-right" class="size-4" />
</button>
<button
:if={@is_legal_page}
phx-click="regenerate_legal"
data-confirm="Regenerate this page's content from your current shop settings? Any manual edits will be replaced."
class="admin-btn admin-btn-sm admin-btn-ghost"
>
<.icon name="hero-arrow-path" class="size-4" /> Regenerate
</button>
<button
:if={!@is_custom_page}
phx-click="reset_defaults"
@@ -388,10 +541,18 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
{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>
<%!-- Status badges --%>
<div class="mt-4 flex gap-2 flex-wrap">
<p :if={@dirty} class="admin-badge admin-badge-warning">
Unsaved changes
</p>
<p :if={@content_source == :auto} class="admin-badge admin-badge-info">
Auto-generated from settings
</p>
<p :if={@content_source == :custom} class="admin-badge">
Customised
</p>
</div>
<div class="page-editor-container">
<%!-- Editor pane --%>
@@ -452,6 +613,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
:if={@image_picker_block_id}
images={@image_picker_images}
search={@image_picker_search}
upload={@uploads.image_picker_upload}
/>
</div>
"""

View File

@@ -285,6 +285,36 @@ defmodule BerrypodWeb.Admin.Theme.Index do
{:noreply, socket}
end
@impl true
def handle_event("update_image_alt", %{"image-id" => image_id, "alt" => alt}, socket) do
case Media.get_image(image_id) do
nil ->
{:noreply, socket}
image ->
{:ok, updated} = Media.update_image_metadata(image, %{alt: alt})
# Refresh the relevant assign so the template sees the new alt text
socket =
cond do
socket.assigns.logo_image && socket.assigns.logo_image.id == image_id ->
assign(socket, :logo_image, updated)
socket.assigns.header_image && socket.assigns.header_image.id == image_id ->
assign(socket, :header_image, updated)
socket.assigns[:icon_image] && socket.assigns.icon_image &&
socket.assigns.icon_image.id == image_id ->
assign(socket, :icon_image, updated)
true ->
socket
end
{:noreply, socket}
end
end
@impl true
def handle_event("remove_logo", _params, socket) do
if logo = socket.assigns.logo_image do

View File

@@ -172,6 +172,29 @@
×
</button>
</div>
<form
phx-change="update_image_alt"
phx-value-image-id={@logo_image.id}
class="mt-2"
>
<label class="flex items-center gap-2">
<span class="text-xs text-base-content/60 shrink-0">Alt text</span>
<input
type="text"
name="alt"
value={@logo_image.alt || ""}
placeholder="Describe this image"
class="admin-input admin-input-sm flex-1"
phx-debounce="blur"
/>
</label>
<p
:if={!@logo_image.alt || @logo_image.alt == ""}
class="text-xs text-warning mt-1"
>
Missing alt text — add a description for accessibility
</p>
</form>
<% end %>
</div>
@@ -449,6 +472,29 @@
×
</button>
</div>
<form
phx-change="update_image_alt"
phx-value-image-id={@header_image.id}
class="mt-2"
>
<label class="flex items-center gap-2">
<span class="text-xs text-base-content/60 shrink-0">Alt text</span>
<input
type="text"
name="alt"
value={@header_image.alt || ""}
placeholder="Describe this image"
class="admin-input admin-input-sm flex-1"
phx-debounce="blur"
/>
</label>
<p
:if={!@header_image.alt || @header_image.alt == ""}
class="text-xs text-warning mt-1"
>
Missing alt text — add a description for accessibility
</p>
</form>
<!-- Header Image Controls -->
<div class="mt-3 flex flex-col gap-3">