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:
@@ -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"
|
||||
|
||||
@@ -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]"}
|
||||
|
||||
Reference in New Issue
Block a user