add admin CRUD for custom CMS pages
All checks were successful
deploy / deploy (push) Successful in 1m21s

New settings form for creating and editing custom page metadata
(title, slug, meta description, published, nav settings). Pages
index shows custom pages section with draft badges and delete.
Editor shows settings button for custom pages, hides reset to
defaults. 20 new tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-28 09:43:03 +00:00
parent bb6b28a163
commit 045be2ed7e
8 changed files with 473 additions and 8 deletions

View File

@@ -0,0 +1,158 @@
defmodule BerrypodWeb.Admin.Pages.CustomForm do
use BerrypodWeb, :live_view
alias Berrypod.Pages
alias Berrypod.Pages.Page
@impl true
def mount(params, _session, socket) do
{:ok, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :new, _params) do
changeset = Page.custom_changeset(%Page{}, %{})
socket
|> assign(:page_title, "New page")
|> assign(:page_struct, %Page{})
|> assign(:slug_touched, false)
|> assign(:form, to_form(changeset))
end
defp apply_action(socket, :edit, %{"slug" => slug}) do
case Pages.get_page_struct(slug) do
%Page{type: "custom"} = page ->
changeset = Page.custom_changeset(page, %{})
socket
|> assign(:page_title, "#{page.title} settings")
|> assign(:page_struct, page)
|> assign(:slug_touched, true)
|> assign(:form, to_form(changeset))
_ ->
socket
|> put_flash(:error, "Page not found")
|> push_navigate(to: ~p"/admin/pages")
end
end
@impl true
def handle_event("validate", %{"page" => params}, socket) do
params = maybe_slugify(params, socket)
form =
socket.assigns.page_struct
|> Page.custom_changeset(params)
|> Map.put(:action, :validate)
|> to_form()
slug_touched =
socket.assigns.slug_touched || (params["slug"] && params["slug"] != "")
{:noreply, assign(socket, form: form, slug_touched: slug_touched)}
end
@impl true
def handle_event("save", %{"page" => params}, socket) do
save_page(socket, socket.assigns.live_action, params)
end
defp save_page(socket, :new, params) do
case Pages.create_custom_page(params) do
{:ok, page} ->
{:noreply,
socket
|> put_flash(:info, "Page created")
|> push_navigate(to: ~p"/admin/pages/#{page.slug}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp save_page(socket, :edit, params) do
case Pages.update_custom_page(socket.assigns.page_struct, params) do
{:ok, page} ->
{:noreply,
socket
|> put_flash(:info, "Page settings saved")
|> push_navigate(to: ~p"/admin/pages/#{page.slug}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
# Auto-generate slug from title when creating and slug hasn't been manually edited
defp maybe_slugify(params, %{assigns: %{live_action: :new, slug_touched: false}}) do
case params["title"] do
title when is_binary(title) and title != "" ->
slug =
title
|> String.downcase()
|> String.replace(~r/[^a-z0-9\s-]/, "")
|> String.trim()
|> String.replace(~r/\s+/, "-")
|> String.replace(~r/-+/, "-")
|> String.trim("-")
Map.put(params, "slug", slug)
_ ->
params
end
end
defp maybe_slugify(params, _socket), do: params
@impl true
def render(assigns) do
~H"""
<.link
navigate={~p"/admin/pages"}
class="text-sm font-normal text-base-content/60 hover:underline"
>
&larr; Pages
</.link>
<.header>
{if @live_action == :new, do: "New page", else: "Page settings"}
</.header>
<.form
for={@form}
id="custom-page-form"
phx-change="validate"
phx-submit="save"
class="mt-6 space-y-6"
style="max-width: 32rem;"
>
<.input field={@form[:title]} label="Title" />
<.input field={@form[:slug]} label="URL slug" />
<.input
field={@form[:meta_description]}
type="textarea"
label="Meta description"
phx-no-feedback
/>
<.input field={@form[:published]} type="checkbox" label="Published" />
<.input field={@form[:show_in_nav]} type="checkbox" label="Show in navigation" />
<div :if={@form[:show_in_nav].value == true} class="space-y-4 pl-6">
<.input field={@form[:nav_label]} label="Nav label" />
<.input field={@form[:nav_position]} type="number" label="Nav position" />
</div>
<div class="flex gap-3">
<.button type="submit" phx-disable-with="Saving...">
{if @live_action == :new, do: "Create page", else: "Save settings"}
</.button>
<.link navigate={~p"/admin/pages"} class="admin-btn admin-btn-ghost">
Cancel
</.link>
</div>
</.form>
"""
end
end

View File

@@ -2,7 +2,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view
alias Berrypod.{Media, Pages}
alias Berrypod.Pages.{BlockEditor, BlockTypes}
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
alias Berrypod.Products.ProductImage
alias Berrypod.Theme.{Fonts, PreviewData}
@@ -38,7 +38,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)
|> assign(:image_picker_images, [])
|> assign(:image_picker_search, "")}
|> assign(:image_picker_search, "")
|> assign(:is_custom_page, !Page.system_slug?(slug))}
end
# ── Block manipulation events ────────────────────────────────────
@@ -310,7 +311,15 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
/>
{if @show_preview, do: "Edit", else: "Preview"}
</button>
<.link
:if={@is_custom_page}
navigate={~p"/admin/pages/#{@slug}/settings"}
class="admin-btn admin-btn-sm admin-btn-ghost"
>
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings
</.link>
<button
:if={!@is_custom_page}
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"

View File

@@ -14,20 +14,43 @@ defmodule BerrypodWeb.Admin.Pages.Index do
@impl true
def mount(_params, _session, socket) do
pages = Pages.list_pages() |> Map.new(&{&1.slug, &1})
custom_pages = Pages.list_custom_pages()
{:ok,
socket
|> assign(:page_title, "Pages")
|> assign(:pages, pages)
|> assign(:custom_pages, custom_pages)
|> assign(:page_groups, @page_groups)}
end
@impl true
def handle_event("delete_custom_page", %{"slug" => slug}, socket) do
case Pages.get_page_struct(slug) do
%{type: "custom"} = page ->
{:ok, _} = Pages.delete_custom_page(page)
{:noreply,
socket
|> assign(:custom_pages, Pages.list_custom_pages())
|> put_flash(:info, "Page deleted")}
_ ->
{:noreply, put_flash(socket, :error, "Page not found")}
end
end
@impl true
def render(assigns) do
~H"""
<.header>
Pages
<:subtitle>Customise the layout and content of every page on your shop.</:subtitle>
<:actions>
<.link navigate={~p"/admin/pages/new"} class="admin-btn admin-btn-sm admin-btn-primary">
<.icon name="hero-plus" class="size-4" /> New page
</.link>
</:actions>
</.header>
<div class="page-list">
@@ -54,6 +77,42 @@ defmodule BerrypodWeb.Admin.Pages.Index do
</.link>
</div>
</div>
<div :if={@custom_pages != []} class="page-group">
<h3 class="page-group-title">Custom pages</h3>
<div class="page-group-cards">
<div :for={page <- @custom_pages} class="page-card page-card-custom">
<.link navigate={~p"/admin/pages/#{page.slug}"} class="page-card-link">
<span class="page-card-icon">
<.icon name="hero-document" class="size-5" />
</span>
<span class="page-card-info">
<span class="page-card-title">
{page.title}
<span :if={!page.published} class="admin-badge admin-badge-warning ml-2">
Draft
</span>
</span>
<span class="page-card-meta">
/{page.slug} · {length(page.blocks)} {if length(page.blocks) == 1,
do: "block",
else: "blocks"}
</span>
</span>
<.icon name="hero-chevron-right-mini" class="size-4 page-card-arrow" />
</.link>
<button
phx-click="delete_custom_page"
phx-value-slug={page.slug}
data-confirm={"Delete \"#{page.title}\"? This cannot be undone."}
class="page-card-delete"
aria-label={"Delete #{page.title}"}
>
<.icon name="hero-trash" class="size-4" />
</button>
</div>
</div>
</div>
</div>
"""
end