add nav editors to Site tab with live preview
All checks were successful
deploy / deploy (push) Successful in 3m27s

- 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>
This commit is contained in:
jamey
2026-03-28 22:19:48 +00:00
parent 5a5103bc42
commit 7c07805df8
24 changed files with 1068 additions and 1130 deletions

View File

@@ -20,10 +20,11 @@ defmodule BerrypodWeb.PageEditorHook do
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [attach_hook: 4, push_navigate: 2, push_patch: 2]
alias Berrypod.{Media, Settings, Site}
alias Berrypod.{Media, Settings}
alias Berrypod.Pages
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
alias Berrypod.Theme.{Contrast, CSSGenerator, Presets}
alias BerrypodWeb.SiteEditorState
def on_mount(:mount_page_editor, _params, _session, socket) do
socket =
@@ -66,19 +67,11 @@ defmodule BerrypodWeb.PageEditorHook do
# Settings editing state
|> assign(:settings_dirty, false)
|> assign(:settings_save_status, :idle)
# Site editing state
# Site editing state (using SiteEditorState struct)
|> assign(:site_editing, false)
|> assign(:site_state, nil)
|> assign(:site_dirty, false)
|> assign(:site_editor_original, nil)
|> assign(:site_header_nav, [])
|> assign(:site_footer_nav, [])
|> assign(:site_social_links, [])
|> assign(:site_announcement_text, "")
|> assign(:site_announcement_link, "")
|> assign(:site_announcement_style, "info")
|> assign(:site_footer_about, "")
|> assign(:site_footer_copyright, "")
|> assign(:site_footer_show_newsletter, true)
|> assign(:site_nav_pages, [])
# Navigation warning state
|> assign(:editor_nav_blocked, nil)
|> attach_hook(:editor_params, :handle_params, &handle_editor_params/3)
@@ -999,89 +992,46 @@ defmodule BerrypodWeb.PageEditorHook do
defp handle_site_action("update", _params, socket), do: {:halt, socket}
# Social link CRUD operations (persist immediately like images)
# Social link CRUD operations (using SiteEditorState)
defp handle_site_action("add_social_link", _params, socket) do
# Create with "custom" platform and blank URL
# User will paste their link, which auto-detects the platform
position = length(socket.assigns.site_social_links)
attrs = %{
platform: "custom",
url: "",
position: position
}
case Site.create_social_link(attrs) do
{:ok, link} ->
links = socket.assigns.site_social_links ++ [link]
{:halt, assign(socket, :site_social_links, links)}
{:error, _changeset} ->
{:halt, socket}
end
socket = update_site_state(socket, &SiteEditorState.add_social_link/1)
{:halt, socket}
end
defp handle_site_action("update_social_link", %{"id" => id} = params, socket) do
link = Enum.find(socket.assigns.site_social_links, &(&1.id == id))
state = socket.assigns.site_state
link = Enum.find(state.social_links, &(&1.id == id))
if link do
# Extract the nested params from the form field name
link_params = params["social_link"][id] || %{}
target = params["_target"] || []
changed_field = List.last(target)
# Check what field was changed
url_changed? = List.last(target) == "url"
platform_changed? = List.last(target) == "platform"
# Build attrs based on what changed
attrs =
cond do
# URL changed - normalize and update URL, maybe auto-detect platform
url_changed? ->
case changed_field do
"url" ->
url = link_params["url"] |> Berrypod.Site.SocialLink.normalize_url()
detected = Berrypod.Site.SocialLink.detect_platform(url)
base = %{url: url}
# Auto-detect platform when:
# 1. Current platform is "custom" (initial state), OR
# 2. New URL detects to a different platform than currently set
# (e.g., changing from github.com to twitter.com)
should_update_platform? =
detected &&
detected != "custom" &&
(link.platform == "custom" || detected != link.platform)
if should_update_platform? do
Map.put(base, :platform, detected)
else
base
end
if should_update_platform?, do: Map.put(base, :platform, detected), else: base
# Platform explicitly changed by user - use their selection
platform_changed? ->
"platform" ->
%{platform: link_params["platform"]}
# Fallback
true ->
_ ->
%{}
|> maybe_put(:url, link_params["url"])
|> maybe_put(:platform, link_params["platform"])
end
if attrs != %{} do
case Site.update_social_link(link, attrs) do
{:ok, updated_link} ->
links =
Enum.map(socket.assigns.site_social_links, fn l ->
if l.id == id, do: updated_link, else: l
end)
{:halt, assign(socket, :site_social_links, links)}
{:error, _changeset} ->
{:halt, socket}
end
socket = update_site_state(socket, &SiteEditorState.update_social_link(&1, id, attrs))
{:halt, socket}
else
{:halt, socket}
end
@@ -1091,119 +1041,149 @@ defmodule BerrypodWeb.PageEditorHook do
end
defp handle_site_action("remove_social_link", %{"id" => id}, socket) do
link = Enum.find(socket.assigns.site_social_links, &(&1.id == id))
socket = update_site_state(socket, &SiteEditorState.remove_social_link(&1, id))
{:halt, socket}
end
if link do
case Site.delete_social_link(link) do
{:ok, _} ->
links = Enum.reject(socket.assigns.site_social_links, &(&1.id == id))
{:halt, assign(socket, :site_social_links, links)}
defp handle_site_action("move_social_link", %{"id" => id, "dir" => dir}, socket) do
direction = if dir == "up", do: :up, else: :down
socket = update_site_state(socket, &SiteEditorState.move_social_link(&1, id, direction))
{:halt, socket}
end
{:error, _} ->
{:halt, socket}
# Navigation item CRUD operations (using SiteEditorState)
defp handle_site_action("add_nav_item", %{"location" => location}, socket) do
socket = update_site_state(socket, &SiteEditorState.add_nav_item(&1, location))
{:halt, socket}
end
defp handle_site_action(
"update_nav_item",
%{"id" => id, "location" => location} = params,
socket
) do
state = socket.assigns.site_state
items = if location == "header", do: state.header_nav, else: state.footer_nav
item = Enum.find(items, &(&1.id == id))
if item do
item_params = params["nav_item"][id] || %{}
target = params["_target"] || []
changed_field = List.last(target)
attrs =
case changed_field do
"label" ->
%{label: item_params["label"]}
"url" ->
%{url: item_params["url"]}
"link_type" ->
if item_params["link_type"] == "page", do: %{url: ""}, else: %{}
"page_id" ->
page_slug = item_params["page_id"]
if page_slug && page_slug != "" do
url = if String.starts_with?(page_slug, "/"), do: page_slug, else: "/#{page_slug}"
%{url: url}
else
%{}
end
_ ->
%{}
end
if attrs != %{} do
socket =
update_site_state(socket, &SiteEditorState.update_nav_item(&1, location, id, attrs))
{:halt, socket}
else
{:halt, socket}
end
else
{:halt, socket}
end
end
defp handle_site_action("move_social_link", %{"id" => id, "dir" => dir}, socket) do
links = socket.assigns.site_social_links
index = Enum.find_index(links, &(&1.id == id))
defp handle_site_action("remove_nav_item", %{"id" => id, "location" => location}, socket) do
socket = update_site_state(socket, &SiteEditorState.remove_nav_item(&1, location, id))
{:halt, socket}
end
new_index =
case dir do
"up" -> max(0, index - 1)
"down" -> min(length(links) - 1, index + 1)
_ -> index
end
defp handle_site_action(
"move_nav_item",
%{"id" => id, "location" => location, "dir" => dir},
socket
) do
direction = if dir == "up", do: :up, else: :down
if index != new_index do
# Swap the items
item = Enum.at(links, index)
other = Enum.at(links, new_index)
socket =
update_site_state(socket, &SiteEditorState.move_nav_item(&1, location, id, direction))
reordered =
links
|> List.replace_at(index, other)
|> List.replace_at(new_index, item)
# Persist the new order
ids = Enum.map(reordered, & &1.id)
Site.reorder_social_links(ids)
{:halt, assign(socket, :site_social_links, reordered)}
else
{:halt, socket}
end
{:halt, socket}
end
# Catch-all for unknown site actions
defp handle_site_action(_action, _params, socket), do: {:halt, socket}
defp handle_site_update(socket, params) do
# Handle announcement bar fields (preview only, no persistence)
socket =
state = socket.assigns.site_state
# Handle announcement bar fields
state =
if Map.has_key?(params, "announcement_text") or
Map.has_key?(params, "announcement_link") or
Map.has_key?(params, "announcement_style") do
text = params["announcement_text"] || socket.assigns.site_announcement_text
link = params["announcement_link"] || socket.assigns.site_announcement_link
style = params["announcement_style"] || socket.assigns.site_announcement_style
text = params["announcement_text"] || state.announcement_text
link = params["announcement_link"] || state.announcement_link
style = params["announcement_style"] || state.announcement_style
socket
|> assign(:site_announcement_text, text)
|> assign(:site_announcement_link, link)
|> assign(:site_announcement_style, style)
state
|> SiteEditorState.put(:announcement_text, text)
|> SiteEditorState.put(:announcement_link, link)
|> SiteEditorState.put(:announcement_style, style)
else
socket
state
end
# Handle footer fields (preview only, no persistence)
socket =
# Handle footer fields
state =
if Map.has_key?(params, "footer_about") or
Map.has_key?(params, "footer_copyright") or
Map.has_key?(params, "show_newsletter") do
about = params["footer_about"] || socket.assigns.site_footer_about
copyright = params["footer_copyright"] || socket.assigns.site_footer_copyright
about = params["footer_about"] || state.footer_about
copyright = params["footer_copyright"] || state.footer_copyright
# Checkbox sends value when checked, absent when unchecked
show_newsletter =
if Map.has_key?(params, "show_newsletter") do
params["show_newsletter"] == "true"
else
false
end
if Map.has_key?(params, "show_newsletter"),
do: params["show_newsletter"] == "true",
else: false
socket
|> assign(:site_footer_about, about)
|> assign(:site_footer_copyright, copyright)
|> assign(:site_footer_show_newsletter, show_newsletter)
state
|> SiteEditorState.put(:footer_about, about)
|> SiteEditorState.put(:footer_copyright, copyright)
|> SiteEditorState.put(:show_newsletter, show_newsletter)
else
socket
state
end
# Mark as dirty if values differ from original
socket
|> compute_site_dirty()
|> assign(:site_state, state)
|> assign(:site_dirty, SiteEditorState.dirty?(state))
end
defp compute_site_dirty(socket) do
original = socket.assigns[:site_editor_original]
# Helper to update site state and compute dirty flag
defp update_site_state(socket, update_fn) do
state = socket.assigns.site_state
new_state = update_fn.(state)
dirty =
if original do
socket.assigns.site_announcement_text != original.announcement_text or
socket.assigns.site_announcement_link != original.announcement_link or
socket.assigns.site_announcement_style != original.announcement_style or
socket.assigns.site_footer_about != original.footer_about or
socket.assigns.site_footer_copyright != original.footer_copyright or
socket.assigns.site_footer_show_newsletter != original.show_newsletter
else
false
end
assign(socket, :site_dirty, dirty)
socket
|> assign(:site_state, new_state)
|> assign(:site_dirty, SiteEditorState.dirty?(new_state))
end
# Check if settings have changed from current page values
@@ -1417,34 +1397,34 @@ defmodule BerrypodWeb.PageEditorHook do
# ── Site editing helpers ───────────────────────────────────────────
defp load_site_state(socket) do
site_settings = Site.get_settings()
# Store original values for revert capability
original = %{
announcement_text: site_settings.announcement_text,
announcement_link: site_settings.announcement_link,
announcement_style: site_settings.announcement_style,
footer_about: site_settings.footer_about,
footer_copyright: site_settings.footer_copyright,
show_newsletter: site_settings.show_newsletter
}
# Load pages for the page picker (system pages that are linkable + custom pages)
nav_pages = load_nav_pages()
socket
|> assign(:site_editing, true)
|> assign(:site_dirty, false)
|> assign(:site_editor_original, original)
|> assign(:site_header_nav, Site.list_nav_items(:header))
|> assign(:site_footer_nav, Site.list_nav_items(:footer))
|> assign(:site_social_links, Site.list_social_links())
|> assign(:site_announcement_text, site_settings.announcement_text)
|> assign(:site_announcement_link, site_settings.announcement_link)
|> assign(:site_announcement_style, site_settings.announcement_style)
|> assign(:site_footer_about, site_settings.footer_about)
|> assign(:site_footer_copyright, site_settings.footer_copyright)
|> assign(:site_footer_show_newsletter, site_settings.show_newsletter)
|> assign(:site_state, SiteEditorState.load())
|> assign(:site_nav_pages, nav_pages)
|> assign(:editor_sheet_state, :open)
end
# Load pages suitable for navigation links
# Excludes non-linkable system pages like cart, checkout_success, error, etc.
defp load_nav_pages do
linkable_system_slugs = ~w(home about delivery privacy terms contact)
system_pages =
Pages.list_system_pages()
|> Enum.filter(&(&1.slug in linkable_system_slugs))
|> Enum.map(&%{slug: &1.slug, title: &1.title})
custom_pages =
Pages.list_custom_pages()
|> Enum.filter(& &1.published)
|> Enum.map(&%{slug: &1.slug, title: &1.title})
system_pages ++ custom_pages
end
# ── Unified save helpers ───────────────────────────────────────────
defp save_all_tabs(socket) do
@@ -1498,31 +1478,13 @@ defmodule BerrypodWeb.PageEditorHook do
end
defp maybe_save_site(socket) do
if socket.assigns[:site_dirty] do
Site.put_announcement(
socket.assigns.site_announcement_text,
socket.assigns.site_announcement_link,
socket.assigns.site_announcement_style
)
state = socket.assigns[:site_state]
Site.put_footer_content(
socket.assigns.site_footer_about,
socket.assigns.site_footer_copyright,
socket.assigns.site_footer_show_newsletter
)
# Update original to match saved values
original = %{
announcement_text: socket.assigns.site_announcement_text,
announcement_link: socket.assigns.site_announcement_link,
announcement_style: socket.assigns.site_announcement_style,
footer_about: socket.assigns.site_footer_about,
footer_copyright: socket.assigns.site_footer_copyright,
show_newsletter: socket.assigns.site_footer_show_newsletter
}
if state && SiteEditorState.dirty?(state) do
saved_state = SiteEditorState.save(state)
socket
|> assign(:site_editor_original, original)
|> assign(:site_state, saved_state)
|> assign(:site_dirty, false)
else
socket
@@ -1577,25 +1539,16 @@ defmodule BerrypodWeb.PageEditorHook do
end
defp maybe_revert_site(socket) do
if socket.assigns[:site_dirty] do
original = socket.assigns.site_editor_original
state = socket.assigns[:site_state]
if state && SiteEditorState.dirty?(state) do
reverted_state = SiteEditorState.revert(state)
socket
|> assign(:site_announcement_text, original.announcement_text)
|> assign(:site_announcement_link, original.announcement_link)
|> assign(:site_announcement_style, original.announcement_style)
|> assign(:site_footer_about, original.footer_about)
|> assign(:site_footer_copyright, original.footer_copyright)
|> assign(:site_footer_show_newsletter, original.show_newsletter)
|> assign(:site_state, reverted_state)
|> assign(:site_dirty, false)
else
socket
end
end
# ── Small helpers ───────────────────────────────────────────────────
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, ""), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end