berrypod/lib/berrypod_web/live/admin/pages/editor.ex
jamey 7c07805df8
All checks were successful
deploy / deploy (push) Successful in 3m27s
add nav editors to Site tab with live preview
- Add header and footer nav editors to Site tab with drag-to-reorder,
  add/remove items, and destination picker (pages, collections, external)
- Live preview updates as you edit nav items
- Remove legacy /admin/navigation page and controller (was saving to
  Settings table, now uses nav_items table)
- Update error_html.ex and pages/editor.ex to load nav from nav_items table
- Update link_scanner to read from nav_items table, edit path now /?edit=site
- Add Site.default_header_nav/0 and default_footer_nav/0 for previews/errors
- Remove fallback logic from theme_hook.ex (database is now source of truth)
- Seed default nav items and social links during setup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-28 22:19:48 +00:00

868 lines
28 KiB
Elixir

defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view
alias Berrypod.{LegalPages, Media, Pages, Products, Site}
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
alias Berrypod.Products.ProductImage
alias Berrypod.Theme.{Fonts, PreviewData}
import BerrypodWeb.BlockEditorComponents
@impl true
def mount(%{"slug" => slug}, _session, socket) do
page = Pages.get_page(slug)
allowed_blocks = BlockTypes.allowed_for(slug)
real_products = Products.list_visible_products(limit: 8)
real_categories = Products.list_categories()
preview_data = %{
products: if(real_products != [], do: real_products, else: PreviewData.products()),
categories: if(real_categories != [], do: real_categories, else: PreviewData.categories()),
cart_items: PreviewData.cart_items()
}
{:ok,
socket
|> assign(:page_title, page.title)
|> assign(:slug, slug)
|> assign(:page_data, page)
|> assign(:blocks, page.blocks)
|> assign(:allowed_blocks, allowed_blocks)
|> assign(:history, [])
|> assign(:future, [])
|> assign(:dirty, false)
|> assign(:save_status, :idle)
|> assign(:show_picker, false)
|> assign(:picker_filter, "")
|> assign(:expanded, MapSet.new())
|> assign(:live_region_message, nil)
|> assign(:show_preview, false)
|> assign(:preview_data, preview_data)
|> assign(:logo_image, Media.get_logo())
|> assign(:header_image, Media.get_header())
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)
|> assign(:image_picker_images, [])
|> assign(:image_picker_search, "")
|> assign(:is_custom_page, !Page.system_slug?(slug))
|> assign(:is_legal_page, LegalPages.legal_slug?(slug))
|> assign_settings_form(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 ────────────────────────────────────
@impl true
def handle_event("move_up", %{"id" => block_id}, socket) do
case BlockEditor.move_up(socket.assigns.blocks, block_id) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event("move_down", %{"id" => block_id}, socket) do
case BlockEditor.move_down(socket.assigns.blocks, block_id) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event("remove_block", %{"id" => block_id}, socket) do
{:ok, new_blocks, message} = BlockEditor.remove_block(socket.assigns.blocks, block_id)
{:noreply, apply_mutation(socket, new_blocks, message)}
end
def handle_event("duplicate_block", %{"id" => block_id}, socket) do
case BlockEditor.duplicate_block(socket.assigns.blocks, block_id) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event("add_block", %{"type" => type}, socket) do
case BlockEditor.add_block(socket.assigns.blocks, type) do
{:ok, new_blocks, message} ->
{:noreply,
socket
|> apply_mutation(new_blocks, message)
|> assign(:show_picker, false)}
:noop ->
{:noreply, socket}
end
end
def handle_event("update_block_settings", params, socket) do
block_id = params["block_id"]
new_settings = params["block_settings"] || %{}
case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do
{:ok, new_blocks} ->
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}
end
end
# ── Repeater events ──────────────────────────────────────────────
def handle_event("repeater_add", %{"block-id" => block_id, "field" => field_key}, socket) do
case BlockEditor.repeater_add(socket.assigns.blocks, block_id, field_key) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event(
"repeater_remove",
%{"block-id" => block_id, "field" => field_key, "index" => index_str},
socket
) do
index = String.to_integer(index_str)
case BlockEditor.repeater_remove(socket.assigns.blocks, block_id, field_key, index) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
def handle_event(
"repeater_move",
%{"block-id" => block_id, "field" => field_key, "index" => index_str, "dir" => dir},
socket
) do
index = String.to_integer(index_str)
case BlockEditor.repeater_move(socket.assigns.blocks, block_id, field_key, index, dir) do
{:ok, new_blocks, message} ->
{:noreply, apply_mutation(socket, new_blocks, message)}
:noop ->
{:noreply, socket}
end
end
# ── Image picker events ──────────────────────────────────────────
def handle_event(
"show_image_picker",
%{"block-id" => block_id, "field" => field_key},
socket
) do
images = Media.list_images()
{:noreply,
socket
|> assign(:image_picker_block_id, block_id)
|> assign(:image_picker_field_key, field_key)
|> assign(:image_picker_images, images)
|> assign(:image_picker_search, "")}
end
def handle_event("hide_image_picker", _params, socket) do
{:noreply,
socket
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)}
end
def handle_event("image_picker_search", %{"value" => value}, socket) do
{:noreply, assign(socket, :image_picker_search, value)}
end
def handle_event("pick_image", %{"image-id" => image_id}, socket) do
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 selected")
|> assign(:image_picker_block_id, nil)
|> assign(:image_picker_field_key, nil)}
:noop ->
{:noreply, socket}
end
end
def handle_event(
"clear_image",
%{"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} ->
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}
end
end
# ── UI events ────────────────────────────────────────────────────
def handle_event("toggle_expand", %{"id" => block_id}, socket) do
expanded = socket.assigns.expanded
block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id))
block_name = BlockEditor.block_display_name(block)
{new_expanded, action} =
if MapSet.member?(expanded, block_id) do
{MapSet.delete(expanded, block_id), "collapsed"}
else
{MapSet.put(expanded, block_id), "expanded"}
end
{:noreply,
socket
|> assign(:expanded, new_expanded)
|> assign(:live_region_message, "#{block_name} settings #{action}")}
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("toggle_preview", _params, socket) do
{:noreply, assign(socket, :show_preview, !socket.assigns.show_preview)}
end
# ── Page actions ─────────────────────────────────────────────────
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)
|> assign(:save_status, :saved)}
{:error, _changeset} ->
{:noreply,
socket
|> assign(:save_status, :error)
|> put_flash(:error, "Failed to save page")}
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)
page = Pages.get_page(slug)
{:noreply,
socket
|> assign(:blocks, page.blocks)
|> assign(:history, [])
|> assign(:future, [])
|> assign(:dirty, false)
|> put_flash(:info, "Page reset to defaults")}
end
def handle_event("undo", _params, socket) do
case socket.assigns.history do
[prev | rest] ->
future = [socket.assigns.blocks | socket.assigns.future]
{:noreply,
socket
|> assign(:blocks, prev)
|> assign(:history, rest)
|> assign(:future, future)
|> assign(:dirty, prev != socket.assigns.page_data.blocks)
|> assign(:live_region_message, "Undone")}
[] ->
{:noreply, socket}
end
end
def handle_event("redo", _params, socket) do
case socket.assigns.future do
[next | rest] ->
history = [socket.assigns.blocks | socket.assigns.history]
{:noreply,
socket
|> assign(:blocks, next)
|> assign(:history, history)
|> assign(:future, rest)
|> assign(:dirty, true)
|> assign(:live_region_message, "Redone")}
[] ->
{:noreply, socket}
end
end
# ── Page settings (custom pages only) ─────────────────────────────
def handle_event("toggle_settings", _params, socket) do
{:noreply, assign(socket, :show_settings, !socket.assigns.show_settings)}
end
def handle_event("validate_settings", %{"page" => params}, socket) do
form =
socket.assigns.page_struct
|> Page.custom_changeset(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, :settings_form, form)}
end
def handle_event("save_settings", %{"page" => params}, socket) do
case Pages.update_custom_page(socket.assigns.page_struct, params) do
{:ok, page} ->
{:noreply,
socket
|> assign(:page_struct, page)
|> assign(:page_data, %{socket.assigns.page_data | title: page.title})
|> assign(:page_title, page.title)
|> assign(:settings_form, to_form(Page.custom_changeset(page, %{})))
|> assign(:show_settings, false)
|> put_flash(:info, "Page settings saved")}
{:error, changeset} ->
{:noreply, assign(socket, :settings_form, to_form(changeset))}
end
end
defp assign_settings_form(socket, slug) do
if Page.system_slug?(slug) do
socket
|> assign(:show_settings, false)
|> assign(:page_struct, nil)
|> assign(:settings_form, nil)
else
page_struct = Pages.get_page_struct(slug)
socket
|> assign(:show_settings, false)
|> assign(:page_struct, page_struct)
|> assign(:settings_form, to_form(Page.custom_changeset(page_struct, %{})))
end
end
# ── Handle info ──────────────────────────────────────────────────
# ── Render ───────────────────────────────────────────────────────
@impl true
def render(assigns) do
~H"""
<div id="page-editor" phx-hook="EditorKeyboard" data-dirty={to_string(@dirty)}>
<.link navigate={~p"/admin/pages"} class="admin-back-link">
&larr; Pages
</.link>
<.header>
{@page_data.title}
<:actions>
<button
phx-click="toggle_preview"
class="admin-btn admin-btn-sm admin-btn-ghost page-editor-toggle-preview"
>
<.icon
name={if @show_preview, do: "hero-pencil-square", else: "hero-eye"}
class="size-4"
/>
{if @show_preview, do: "Edit", else: "Preview"}
</button>
<button
:if={@is_custom_page}
phx-click="toggle_settings"
class={[
"admin-btn admin-btn-sm admin-btn-ghost",
@show_settings && "admin-btn-active"
]}
>
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings
</button>
<button
phx-click="undo"
class="admin-btn admin-btn-sm admin-btn-ghost"
disabled={@history == []}
aria-label="Undo"
>
<.icon name="hero-arrow-uturn-left" class="size-4" />
</button>
<button
phx-click="redo"
class="admin-btn admin-btn-sm admin-btn-ghost"
disabled={@future == []}
aria-label="Redo"
>
<.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"
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"
disabled={!@dirty}
>
Save
</button>
<.inline_feedback status={@save_status} />
</: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>
<%!-- Status badges --%>
<div class="admin-editor-badges">
<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>
<%!-- Inline page settings (custom pages) --%>
<div :if={@show_settings && @settings_form} class="page-settings-panel">
<.form
for={@settings_form}
id="inline-page-settings"
phx-change="validate_settings"
phx-submit="save_settings"
>
<div class="page-settings-fields">
<.input field={@settings_form[:title]} label="Title" />
<.input field={@settings_form[:slug]} label="URL slug" />
<.input
field={@settings_form[:meta_description]}
type="textarea"
label="Meta description"
phx-no-feedback
/>
<div class="page-settings-row">
<.input field={@settings_form[:published]} type="checkbox" label="Published" />
<.input
field={@settings_form[:show_in_nav]}
type="checkbox"
label="Show in navigation"
/>
</div>
</div>
<div class="page-settings-actions">
<.button type="submit" phx-disable-with="Saving..." class="admin-btn-sm">
Save settings
</.button>
<button
type="button"
phx-click="toggle_settings"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
Cancel
</button>
</div>
</.form>
</div>
<div class="page-editor-container">
<%!-- Editor pane --%>
<div class={[
"page-editor-pane",
@show_preview && "page-editor-pane-hidden-mobile"
]}>
<%!-- 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)}
expanded={@expanded}
/>
<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>
</div>
<%!-- Preview pane --%>
<div class={[
"page-editor-preview-pane",
!@show_preview && "page-editor-preview-hidden-mobile"
]}>
<.preview_pane
slug={@slug}
blocks={@blocks}
page_data={@page_data}
preview_data={@preview_data}
theme_settings={@theme_settings}
site_name={@site_name}
generated_css={@generated_css}
logo_image={@logo_image}
header_image={@header_image}
/>
</div>
</div>
<%!-- Block picker modal --%>
<.block_picker
:if={@show_picker}
allowed_blocks={@allowed_blocks}
filter={@picker_filter}
/>
<%!-- Image picker modal --%>
<.image_picker
:if={@image_picker_block_id}
images={@image_picker_images}
search={@image_picker_search}
upload={@uploads.image_picker_upload}
/>
</div>
"""
end
# ── Preview ───────────────────────────────────────────────────────
defp preview_pane(assigns) do
page = %{slug: assigns.slug, title: assigns.page_data.title, blocks: assigns.blocks}
preview =
assigns
|> assign(:page, page)
|> assign(:mode, :preview)
|> assign(:products, assigns.preview_data.products)
|> assign(:categories, assigns.preview_data.categories)
|> assign(:cart_items, PreviewData.cart_drawer_items())
|> assign(:cart_count, 2)
|> assign(:cart_subtotal, "£72.00")
|> assign(:cart_drawer_open, false)
|> assign(:header_nav_items, load_nav_items("header"))
|> assign(:footer_nav_items, load_nav_items("footer"))
|> preview_page_context(assigns.slug)
extra = Pages.load_block_data(page.blocks, preview)
assigns = assign(assigns, :preview, assign(preview, extra))
~H"""
<div
class="page-editor-preview themed"
data-mood={@theme_settings.mood}
data-typography={@theme_settings.typography}
data-shape={@theme_settings.shape}
data-density={@theme_settings.density}
data-grid={@theme_settings.grid_columns}
data-header={@theme_settings.header_layout}
data-sticky={to_string(@theme_settings.sticky_header)}
data-layout={@theme_settings.layout_width}
data-shadow={@theme_settings.card_shadow}
data-button-style={@theme_settings.button_style}
>
<style>
<%= Phoenix.HTML.raw(Fonts.generate_all_font_faces(&BerrypodWeb.Endpoint.static_path/1)) %>
<%= Phoenix.HTML.raw(@generated_css) %>
</style>
<BerrypodWeb.PageRenderer.render_page {@preview} />
</div>
"""
end
defp preview_page_context(assigns, "pdp") do
product = List.first(assigns.preview_data.products)
option_types = Map.get(product, :option_types) || []
variants = Map.get(product, :variants) || []
{selected_options, selected_variant} =
case variants do
[first | _] -> {first.options, first}
[] -> {%{}, nil}
end
available_options =
Enum.reduce(option_types, %{}, fn opt, acc ->
values = Enum.map(opt.values, & &1.title)
Map.put(acc, opt.name, values)
end)
display_price =
if selected_variant, do: selected_variant.price, else: product.cheapest_price
assigns
|> assign(:product, product)
|> assign(:gallery_images, build_gallery_images(product))
|> assign(:option_types, option_types)
|> assign(:selected_options, selected_options)
|> assign(:available_options, available_options)
|> assign(:display_price, display_price)
|> assign(:quantity, 1)
|> assign(:option_urls, %{})
end
defp preview_page_context(assigns, "cart") do
cart_items = assigns.preview_data.cart_items
subtotal =
Enum.reduce(cart_items, 0, fn item, acc ->
acc + item.product.cheapest_price * item.quantity
end)
assigns
|> assign(:cart_page_items, cart_items)
|> assign(:cart_page_subtotal, subtotal)
end
defp preview_page_context(assigns, "about"),
do: assign(assigns, :content_blocks, PreviewData.about_content())
defp preview_page_context(assigns, "delivery"),
do: assign(assigns, :content_blocks, PreviewData.delivery_content())
defp preview_page_context(assigns, "privacy"),
do: assign(assigns, :content_blocks, PreviewData.privacy_content())
defp preview_page_context(assigns, "terms"),
do: assign(assigns, :content_blocks, PreviewData.terms_content())
defp preview_page_context(assigns, "error") do
assign(assigns, %{
error_code: "404",
error_title: "Page Not Found",
error_description:
"Sorry, we couldn't find the page you're looking for. Perhaps you've mistyped the URL or the page has been moved."
})
end
defp preview_page_context(assigns, _slug), do: assigns
defp build_gallery_images(product) do
(Map.get(product, :images) || [])
|> Enum.sort_by(& &1.position)
|> Enum.map(fn img -> ProductImage.url(img, 1200) end)
|> Enum.reject(&is_nil/1)
end
# ── Helpers ──────────────────────────────────────────────────────
defp apply_mutation(socket, new_blocks, message) do
history = [socket.assigns.blocks | socket.assigns.history] |> Enum.take(50)
socket
|> assign(:blocks, new_blocks)
|> assign(:history, history)
|> assign(:future, [])
|> assign(:dirty, true)
|> assign(:save_status, :idle)
|> assign(:live_region_message, message)
end
# Load nav items from database for preview, falling back to defaults if empty
defp load_nav_items(location) do
case Site.nav_items_for_shop(location) do
[] -> default_nav_items(location)
items -> items
end
end
defp default_nav_items("header"), do: Site.default_header_nav()
defp default_nav_items("footer"), do: Site.default_footer_nav()
end