add nav editors to Site tab with live preview
All checks were successful
deploy / deploy (push) Successful in 3m27s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user