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

@@ -88,18 +88,23 @@ defmodule BerrypodWeb.ShopComponents.Layout do
base = Map.take(assigns, @layout_keys)
# When site editor is active, use in-memory values for live preview
# The site_* assigns are the editor's working copies, while announcement_*
# and social_links are the database-loaded values from theme_hook
# The site_state assigns is the editor's working copy, while announcement_*,
# nav items, and social_links are the database-loaded values from theme_hook
# Only override when site_editing is true (editor has loaded site state)
if assigns[:site_editing] do
# Convert raw SocialLink structs to shop format
social_links = format_social_links_for_shop(assigns[:site_social_links] || [])
if assigns[:site_editing] && assigns[:site_state] do
state = assigns[:site_state]
# Convert raw structs to shop format
social_links = format_social_links_for_shop(state.social_links || [])
header_nav = format_nav_items_for_shop(state.header_nav || [])
footer_nav = format_nav_items_for_shop(state.footer_nav || [])
base
|> Map.put(:announcement_text, assigns[:site_announcement_text])
|> Map.put(:announcement_link, assigns[:site_announcement_link])
|> Map.put(:announcement_style, assigns[:site_announcement_style])
|> Map.put(:announcement_text, state.announcement_text)
|> Map.put(:announcement_link, state.announcement_link)
|> Map.put(:announcement_style, state.announcement_style)
|> Map.put(:social_links, social_links)
|> Map.put(:header_nav_items, header_nav)
|> Map.put(:footer_nav_items, footer_nav)
else
base
end
@@ -122,6 +127,41 @@ defmodule BerrypodWeb.ShopComponents.Layout do
end)
end
# Convert raw NavItem structs to the format expected by shop components
# Filters out items with empty labels (incomplete entries still being edited)
defp format_nav_items_for_shop(items) do
items
|> Enum.reject(fn item -> is_nil(item.label) or item.label == "" end)
|> Enum.map(fn item ->
slug = extract_slug_from_url(item.url)
base = %{
"label" => item.label,
"href" => item.url,
"slug" => slug
}
# Add active_slugs for Shop nav item to highlight on collection and pdp pages
if slug == "collection" do
Map.put(base, "active_slugs", ["collection", "pdp"])
else
base
end
end)
end
# Extract a slug from a URL for nav item matching
defp extract_slug_from_url(url) when is_binary(url) do
cond do
url == "/" -> "home"
String.starts_with?(url, "/collections") -> "collection"
String.starts_with?(url, "/products") -> "pdp"
true -> url |> String.trim_leading("/") |> String.split("/") |> List.first() || ""
end
end
defp extract_slug_from_url(_), do: ""
# Social
defp platform_display_label("instagram"), do: "Instagram"
defp platform_display_label("threads"), do: "Threads"

View File

@@ -21,34 +21,41 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
Shows collapsible sections for each category of site-wide content.
Expects assigns from the page editor hook:
- site_header_nav, site_footer_nav, site_social_links
- site_announcement_text, site_announcement_link, site_announcement_style
- site_footer_about, site_footer_copyright, site_footer_show_newsletter
Expects:
- site_state: SiteEditorState struct with all site tab state
- site_nav_pages: list of pages for the nav picker
"""
attr :site_header_nav, :list, default: []
attr :site_footer_nav, :list, default: []
attr :site_social_links, :list, default: []
attr :site_announcement_text, :string, default: ""
attr :site_announcement_link, :string, default: ""
attr :site_announcement_style, :string, default: "info"
attr :site_footer_about, :string, default: ""
attr :site_footer_copyright, :string, default: ""
attr :site_footer_show_newsletter, :boolean, default: true
attr :site_state, :any, required: true
attr :site_nav_pages, :list, default: []
attr :event_prefix, :string, default: "site_"
def site_editor(%{site_state: nil} = assigns) do
~H"""
<div class="editor-site-content">
<p class="admin-text-secondary">Loading site settings...</p>
</div>
"""
end
def site_editor(assigns) do
# Build settings map for child components
state = assigns.site_state
# Extract fields from state for child components
settings = %{
announcement_text: assigns.site_announcement_text,
announcement_link: assigns.site_announcement_link,
announcement_style: assigns.site_announcement_style,
footer_about: assigns.site_footer_about,
footer_copyright: assigns.site_footer_copyright,
show_newsletter: assigns.site_footer_show_newsletter
announcement_text: state.announcement_text,
announcement_link: state.announcement_link,
announcement_style: state.announcement_style,
footer_about: state.footer_about,
footer_copyright: state.footer_copyright,
show_newsletter: state.show_newsletter
}
assigns = assign(assigns, :settings, settings)
assigns =
assigns
|> assign(:settings, settings)
|> assign(:header_nav, state.header_nav)
|> assign(:footer_nav, state.footer_nav)
|> assign(:social_links, state.social_links)
~H"""
<div class="editor-site-content">
@@ -61,7 +68,12 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
</.site_section>
<.site_section title="Header navigation" icon="hero-bars-3">
<.nav_list_placeholder items={@site_header_nav} location="header" />
<.nav_editor
items={@header_nav}
location="header"
pages={@site_nav_pages}
event_prefix={@event_prefix}
/>
</.site_section>
<.site_section title="Footer" icon="hero-document-text">
@@ -69,11 +81,16 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
</.site_section>
<.site_section title="Footer navigation" icon="hero-queue-list">
<.nav_list_placeholder items={@site_footer_nav} location="footer" />
<.nav_editor
items={@footer_nav}
location="footer"
pages={@site_nav_pages}
event_prefix={@event_prefix}
/>
</.site_section>
<.site_section title="Social links" icon="hero-link">
<.social_links_editor links={@site_social_links} event_prefix={@event_prefix} />
<.social_links_editor links={@social_links} event_prefix={@event_prefix} />
</.site_section>
</div>
"""
@@ -93,7 +110,12 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
# Use phx-hook to preserve open state across re-renders
~H"""
<details id={@id} class="site-editor-section" phx-hook="DetailsPreserver" {if @open, do: [open: true], else: []}>
<details
id={@id}
class="site-editor-section"
phx-hook="DetailsPreserver"
{if @open, do: [open: true], else: []}
>
<summary class="site-editor-section-header">
<.icon :if={@icon} name={@icon} class="size-4" />
<span>{@title}</span>
@@ -238,24 +260,131 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
"""
end
# ── Navigation List (placeholder) ───────────────────────────────────
# ── Navigation Editor ────────────────────────────────────────────────
attr :items, :list, required: true
attr :location, :string, required: true
attr :pages, :list, default: []
attr :event_prefix, :string, default: "site_"
defp nav_list_placeholder(assigns) do
defp nav_editor(assigns) do
~H"""
<div class="site-editor-nav-list">
<ul class="site-editor-nav-items">
<li :for={item <- @items} class="site-editor-nav-item">
<span class="site-editor-nav-label">{item.label}</span>
<span class="site-editor-nav-url admin-text-tertiary">{item.url}</span>
<ul :if={@items != []} class="site-editor-nav-items">
<li
:for={{item, index} <- Enum.with_index(@items)}
class="site-editor-nav-item"
data-item-id={item.id}
>
<form
class="site-editor-nav-item-form"
phx-change={@event_prefix <> "update_nav_item"}
phx-value-id={item.id}
phx-value-location={@location}
>
<input
type="text"
name={"nav_item[#{item.id}][label]"}
value={item.label}
class="admin-input admin-input-sm site-editor-nav-label-input"
placeholder="Label"
phx-debounce="300"
aria-label="Link label"
/>
<div class="site-editor-nav-url-row">
<select
name={"nav_item[#{item.id}][link_type]"}
class="admin-select admin-select-sm site-editor-nav-type"
aria-label="Link type"
>
<option value="page" selected={item.page_id != nil}>Page</option>
<option value="url" selected={item.page_id == nil}>Custom URL</option>
</select>
<%= if item.page_id != nil or (item.url == "" and @pages != []) do %>
<select
name={"nav_item[#{item.id}][page_id]"}
class="admin-select admin-select-sm site-editor-nav-page"
aria-label="Page"
>
<option value="">Select a page</option>
<optgroup label="Pages">
<option
:for={page <- @pages}
value={page.slug}
selected={item.url == "/#{page.slug}" or item.url == page.slug}
>
{page.title}
</option>
</optgroup>
<optgroup label="Collections">
<option value="/collections/all" selected={item.url == "/collections/all"}>
All products
</option>
</optgroup>
</select>
<% else %>
<input
type="text"
name={"nav_item[#{item.id}][url]"}
value={item.url}
class="admin-input admin-input-sm site-editor-nav-url-input"
placeholder="/about or https://..."
phx-debounce="300"
aria-label="URL"
/>
<% end %>
</div>
</form>
<div class="site-editor-nav-item-actions">
<button
type="button"
class="admin-icon-button"
phx-click={@event_prefix <> "move_nav_item"}
phx-value-id={item.id}
phx-value-location={@location}
phx-value-dir="up"
disabled={index == 0}
aria-label="Move up"
>
<.icon name="hero-chevron-up-mini" class="size-4" />
</button>
<button
type="button"
class="admin-icon-button"
phx-click={@event_prefix <> "move_nav_item"}
phx-value-id={item.id}
phx-value-location={@location}
phx-value-dir="down"
disabled={index == length(@items) - 1}
aria-label="Move down"
>
<.icon name="hero-chevron-down-mini" class="size-4" />
</button>
<button
type="button"
class="admin-icon-button admin-icon-button-danger"
phx-click={@event_prefix <> "remove_nav_item"}
phx-value-id={item.id}
phx-value-location={@location}
aria-label="Remove"
>
<.icon name="hero-x-mark-mini" class="size-4" />
</button>
</div>
</li>
</ul>
<p :if={@items == []} class="admin-text-tertiary">No navigation items</p>
<p class="admin-help-text">
Drag to reorder. Full editing coming in the next phase.
<p :if={@items == []} class="admin-text-tertiary admin-empty-message">
No navigation items yet
</p>
<button
type="button"
class="admin-button admin-button-sm admin-button-outline site-editor-add-button"
phx-click={@event_prefix <> "add_nav_item"}
phx-value-location={@location}
>
<.icon name="hero-plus-mini" class="size-4" />
<span>Add link</span>
</button>
</div>
"""
end
@@ -278,7 +407,11 @@ defmodule BerrypodWeb.ShopComponents.SiteEditor do
class="site-editor-social-item"
data-link-id={link.id}
>
<form class="site-editor-social-item-content" phx-change={@event_prefix <> "update_social_link"} phx-value-id={link.id}>
<form
class="site-editor-social-item-content"
phx-change={@event_prefix <> "update_social_link"}
phx-value-id={link.id}
>
<input
type="text"
name={"social_link[#{link.id}][url]"}