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:
@@ -1,472 +0,0 @@
|
||||
defmodule BerrypodWeb.Admin.Navigation do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.{Pages, Products, Settings}
|
||||
alias BerrypodWeb.ThemeHook
|
||||
|
||||
# System pages that are always available
|
||||
@system_pages [
|
||||
%{href: "/", label: "Home"},
|
||||
%{href: "/about", label: "About"},
|
||||
%{href: "/contact", label: "Contact"},
|
||||
%{href: "/cart", label: "Cart"},
|
||||
%{href: "/search", label: "Search"},
|
||||
%{href: "/delivery", label: "Delivery"},
|
||||
%{href: "/privacy", label: "Privacy policy"},
|
||||
%{href: "/terms", label: "Terms & conditions"}
|
||||
]
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
header_items = load_nav("header_nav", ThemeHook.default_header_nav())
|
||||
footer_items = load_nav("footer_nav", ThemeHook.default_footer_nav())
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Navigation")
|
||||
|> assign(:header_items, header_items)
|
||||
|> assign(:footer_items, footer_items)
|
||||
|> assign(:dirty, false)
|
||||
|> assign(:save_status, :idle)
|
||||
|> assign(:save_error, nil)
|
||||
|> assign_link_options()}
|
||||
end
|
||||
|
||||
defp assign_link_options(socket) do
|
||||
custom_pages = Pages.list_custom_pages()
|
||||
categories = Products.list_categories()
|
||||
|
||||
# Build grouped link options
|
||||
link_options = [
|
||||
{"Pages", Enum.map(@system_pages, &{&1.label, &1.href})},
|
||||
{"Custom pages", Enum.map(custom_pages, fn p -> {p.title, "/#{p.slug}"} end)},
|
||||
{"Collections", Enum.map(categories, fn c -> {c.name, "/collections/#{c.slug}"} end)},
|
||||
{"Other", [{"External URL", "external"}]}
|
||||
]
|
||||
|
||||
# Filter out empty groups
|
||||
link_options = Enum.reject(link_options, fn {_label, opts} -> opts == [] end)
|
||||
|
||||
socket
|
||||
|> assign(:link_options, link_options)
|
||||
|> assign(:custom_pages, custom_pages)
|
||||
|> assign(:categories, categories)
|
||||
end
|
||||
|
||||
# ── Item manipulation ──────────────────────────────────────────
|
||||
|
||||
@impl true
|
||||
def handle_event("nav_item_change", params, socket) do
|
||||
section = params["section"]
|
||||
index = String.to_integer(params["index"])
|
||||
label = params["label"]
|
||||
dest = params["dest"]
|
||||
external_url = params["external_url"]
|
||||
|
||||
items = get_items(socket, section)
|
||||
item = Enum.at(items, index)
|
||||
|
||||
if item do
|
||||
updated =
|
||||
cond do
|
||||
# User selected "External URL"
|
||||
dest == "external" ->
|
||||
item
|
||||
|> Map.put("label", label)
|
||||
|> Map.put("href", external_url || "")
|
||||
|> Map.put("external", true)
|
||||
|
||||
# User selected an internal link
|
||||
dest != nil && dest != "" ->
|
||||
# Auto-fill label if it was empty and user just selected a destination
|
||||
auto_label =
|
||||
if label == "" || label == item["label"],
|
||||
do: label_for_href(dest, socket),
|
||||
else: label
|
||||
|
||||
item
|
||||
|> Map.put("label", if(label == "", do: auto_label, else: label))
|
||||
|> Map.put("href", dest)
|
||||
|> Map.delete("external")
|
||||
|
||||
# Just updating the label
|
||||
true ->
|
||||
Map.put(item, "label", label)
|
||||
end
|
||||
|
||||
items = List.replace_at(items, index, updated)
|
||||
{:noreply, put_items(socket, section, items) |> clear_save_status()}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("remove_item", %{"section" => section, "index" => index_str}, socket) do
|
||||
index = String.to_integer(index_str)
|
||||
items = get_items(socket, section) |> List.delete_at(index)
|
||||
{:noreply, put_items(socket, section, items) |> clear_save_status()}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"move_item",
|
||||
%{"section" => section, "index" => index_str, "dir" => dir},
|
||||
socket
|
||||
) do
|
||||
index = String.to_integer(index_str)
|
||||
items = get_items(socket, section)
|
||||
target = if dir == "up", do: index - 1, else: index + 1
|
||||
|
||||
if target >= 0 and target < length(items) do
|
||||
item = Enum.at(items, index)
|
||||
items = items |> List.delete_at(index) |> List.insert_at(target, item)
|
||||
{:noreply, put_items(socket, section, items) |> clear_save_status()}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("add_item", %{"section" => section}, socket) do
|
||||
# Add a new empty item - user will pick destination from dropdown
|
||||
items = get_items(socket, section) ++ [%{"label" => "", "href" => ""}]
|
||||
{:noreply, put_items(socket, section, items) |> clear_save_status()}
|
||||
end
|
||||
|
||||
def handle_event("save", _params, socket) do
|
||||
all_items = socket.assigns.header_items ++ socket.assigns.footer_items
|
||||
errors = validate_nav_items(all_items)
|
||||
|
||||
if errors == [] do
|
||||
Settings.put_setting("header_nav", socket.assigns.header_items, "json")
|
||||
Settings.put_setting("footer_nav", socket.assigns.footer_items, "json")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:dirty, false)
|
||||
|> assign(:save_status, :saved)
|
||||
|> assign(:save_error, nil)}
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:save_status, :error)
|
||||
|> assign(:save_error, Enum.join(errors, ". "))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("reset_defaults", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:header_items, ThemeHook.default_header_nav())
|
||||
|> assign(:footer_items, ThemeHook.default_footer_nav())
|
||||
|> assign(:dirty, true)
|
||||
|> clear_save_status()}
|
||||
end
|
||||
|
||||
# ── Private helpers for events ───────────────────────────────────
|
||||
|
||||
defp label_for_href(href, socket) do
|
||||
# Check system pages
|
||||
case Enum.find(@system_pages, &(&1.href == href)) do
|
||||
%{label: label} ->
|
||||
label
|
||||
|
||||
nil ->
|
||||
# Check custom pages
|
||||
case Enum.find(socket.assigns.custom_pages, &("/#{&1.slug}" == href)) do
|
||||
%{title: title} ->
|
||||
title
|
||||
|
||||
nil ->
|
||||
# Check collections
|
||||
case Enum.find(socket.assigns.categories, &("/collections/#{&1.slug}" == href)) do
|
||||
%{name: name} -> name
|
||||
nil -> ""
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_nav_items(items) do
|
||||
items
|
||||
|> Enum.with_index()
|
||||
|> Enum.flat_map(fn {item, _idx} ->
|
||||
cond do
|
||||
# Empty label
|
||||
item["label"] == "" || item["label"] == nil ->
|
||||
["All links need a label"]
|
||||
|
||||
# Empty href
|
||||
item["href"] == "" || item["href"] == nil ->
|
||||
["\"#{item["label"]}\" needs a destination"]
|
||||
|
||||
# External URL validation
|
||||
item["external"] == true || is_external_url?(item["href"]) ->
|
||||
if valid_url?(item["href"]) do
|
||||
[]
|
||||
else
|
||||
["\"#{item["label"]}\" has an invalid URL"]
|
||||
end
|
||||
|
||||
true ->
|
||||
[]
|
||||
end
|
||||
end)
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
defp valid_url?(url) when is_binary(url) do
|
||||
case URI.parse(url) do
|
||||
%URI{scheme: scheme, host: host}
|
||||
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
|
||||
true
|
||||
|
||||
_ ->
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
defp valid_url?(_), do: false
|
||||
|
||||
defp clear_save_status(socket) do
|
||||
socket
|
||||
|> assign(:save_status, :idle)
|
||||
|> assign(:save_error, nil)
|
||||
end
|
||||
|
||||
# ── Render ─────────────────────────────────────────────────────
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="nav-editor" phx-hook="DirtyGuard" data-dirty={to_string(@dirty)}>
|
||||
<.header>
|
||||
Navigation
|
||||
<:subtitle>Configure the links in your shop header and footer.</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="admin-nav-layout">
|
||||
<p :if={@dirty} class="admin-badge admin-badge-warning">
|
||||
Unsaved changes
|
||||
</p>
|
||||
<.nav_section
|
||||
title="Header navigation"
|
||||
section="header"
|
||||
items={@header_items}
|
||||
link_options={@link_options}
|
||||
/>
|
||||
|
||||
<.nav_section
|
||||
title="Footer navigation"
|
||||
section="footer"
|
||||
items={@footer_items}
|
||||
link_options={@link_options}
|
||||
/>
|
||||
|
||||
<form
|
||||
action={~p"/admin/navigation"}
|
||||
method="post"
|
||||
phx-submit="save"
|
||||
class="admin-row admin-row-lg"
|
||||
>
|
||||
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
|
||||
<input type="hidden" name="header_nav" value={Jason.encode!(@header_items)} />
|
||||
<input type="hidden" name="footer_nav" value={Jason.encode!(@footer_items)} />
|
||||
<button
|
||||
type="submit"
|
||||
class="admin-btn admin-btn-primary"
|
||||
disabled={!@dirty}
|
||||
phx-disable-with="Saving..."
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<.inline_feedback status={@save_status} message={@save_error} />
|
||||
</form>
|
||||
<div class="admin-row">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="reset_defaults"
|
||||
data-confirm="Reset navigation to defaults? Your changes will be lost."
|
||||
class="admin-btn admin-btn-ghost"
|
||||
>
|
||||
Reset to defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp nav_section(assigns) do
|
||||
~H"""
|
||||
<section>
|
||||
<h3 class="admin-nav-section-heading">
|
||||
{@title}
|
||||
</h3>
|
||||
|
||||
<div class="admin-stack admin-stack-sm">
|
||||
<div :if={@items != []} class="nav-editor-labels" aria-hidden="true">
|
||||
<span>Label</span>
|
||||
<span>Destination</span>
|
||||
</div>
|
||||
<.nav_item
|
||||
:for={{item, idx} <- Enum.with_index(@items)}
|
||||
item={item}
|
||||
idx={idx}
|
||||
section={@section}
|
||||
total={length(@items)}
|
||||
link_options={@link_options}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :if={@items == []} class="admin-nav-empty">
|
||||
No items yet.
|
||||
</div>
|
||||
|
||||
<div class="admin-nav-actions">
|
||||
<button
|
||||
phx-click="add_item"
|
||||
phx-value-section={@section}
|
||||
class="admin-btn admin-btn-sm admin-btn-outline"
|
||||
>
|
||||
<.icon name="hero-plus" class="size-4" /> Add link
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
defp nav_item(assigns) do
|
||||
# Determine if this is an external link
|
||||
is_external = assigns.item["external"] == true || is_external_url?(assigns.item["href"])
|
||||
selected_value = if is_external, do: "external", else: assigns.item["href"]
|
||||
|
||||
# Check if external URL is valid (only if there's a value)
|
||||
url_invalid =
|
||||
is_external && assigns.item["href"] != "" && assigns.item["href"] != nil &&
|
||||
!valid_url?(assigns.item["href"])
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:is_external, is_external)
|
||||
|> assign(:selected_value, selected_value)
|
||||
|> assign(:url_invalid, url_invalid)
|
||||
|
||||
~H"""
|
||||
<form
|
||||
class="nav-editor-item"
|
||||
phx-change="nav_item_change"
|
||||
phx-value-section={@section}
|
||||
phx-value-index={@idx}
|
||||
>
|
||||
<div class="nav-editor-fields">
|
||||
<label class="sr-only" for={"nav-#{@section}-#{@idx}-label"}>Label</label>
|
||||
<input
|
||||
type="text"
|
||||
id={"nav-#{@section}-#{@idx}-label"}
|
||||
name="label"
|
||||
value={@item["label"]}
|
||||
placeholder="Label"
|
||||
class="admin-input nav-editor-input"
|
||||
/>
|
||||
<label class="sr-only" for={"nav-#{@section}-#{@idx}-dest"}>Destination</label>
|
||||
<select
|
||||
id={"nav-#{@section}-#{@idx}-dest"}
|
||||
name="dest"
|
||||
class="admin-input nav-editor-select"
|
||||
>
|
||||
<option value="" disabled selected={@selected_value == ""}>
|
||||
Choose destination...
|
||||
</option>
|
||||
<%= for {group_label, options} <- @link_options do %>
|
||||
<optgroup label={group_label}>
|
||||
<%= for {label, href} <- options do %>
|
||||
<option value={href} selected={@selected_value == href}>
|
||||
{label}
|
||||
</option>
|
||||
<% end %>
|
||||
</optgroup>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<%!-- External URL input (shown when "External URL" is selected) --%>
|
||||
<div :if={@is_external} class="nav-editor-external">
|
||||
<label class="sr-only" for={"nav-#{@section}-#{@idx}-url"}>URL</label>
|
||||
<input
|
||||
type="url"
|
||||
id={"nav-#{@section}-#{@idx}-url"}
|
||||
name="external_url"
|
||||
value={@item["href"]}
|
||||
placeholder="https://example.com"
|
||||
class={["admin-input nav-editor-input", @url_invalid && "admin-input-error"]}
|
||||
/>
|
||||
<span :if={@url_invalid} class="nav-editor-error">
|
||||
<.icon name="hero-exclamation-circle" class="size-4" />
|
||||
Enter a valid URL starting with https://
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="nav-editor-actions">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="move_item"
|
||||
phx-value-section={@section}
|
||||
phx-value-index={@idx}
|
||||
phx-value-dir="up"
|
||||
disabled={@idx == 0}
|
||||
class="admin-btn admin-btn-xs admin-btn-ghost"
|
||||
aria-label="Move up"
|
||||
>
|
||||
<.icon name="hero-chevron-up" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="move_item"
|
||||
phx-value-section={@section}
|
||||
phx-value-index={@idx}
|
||||
phx-value-dir="down"
|
||||
disabled={@idx == @total - 1}
|
||||
class="admin-btn admin-btn-xs admin-btn-ghost"
|
||||
aria-label="Move down"
|
||||
>
|
||||
<.icon name="hero-chevron-down" class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_item"
|
||||
phx-value-section={@section}
|
||||
phx-value-index={@idx}
|
||||
class="admin-btn admin-btn-xs admin-btn-ghost"
|
||||
aria-label="Remove"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
"""
|
||||
end
|
||||
|
||||
defp is_external_url?(nil), do: false
|
||||
defp is_external_url?(""), do: false
|
||||
|
||||
defp is_external_url?(href) do
|
||||
String.starts_with?(href, "http://") || String.starts_with?(href, "https://")
|
||||
end
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
defp get_items(socket, "header"), do: socket.assigns.header_items
|
||||
defp get_items(socket, "footer"), do: socket.assigns.footer_items
|
||||
|
||||
defp put_items(socket, "header", items),
|
||||
do: socket |> assign(:header_items, items) |> assign(:dirty, true)
|
||||
|
||||
defp put_items(socket, "footer", items),
|
||||
do: socket |> assign(:footer_items, items) |> assign(:dirty, true)
|
||||
|
||||
defp load_nav(key, default) do
|
||||
case Settings.get_setting(key) do
|
||||
items when is_list(items) -> items
|
||||
_ -> default
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.{LegalPages, Media, Pages, Products}
|
||||
alias Berrypod.{LegalPages, Media, Pages, Products, Site}
|
||||
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
|
||||
alias Berrypod.Products.ProductImage
|
||||
alias Berrypod.Theme.{Fonts, PreviewData}
|
||||
@@ -736,8 +736,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
|> assign(:cart_count, 2)
|
||||
|> assign(:cart_subtotal, "£72.00")
|
||||
|> assign(:cart_drawer_open, false)
|
||||
|> assign(:header_nav_items, BerrypodWeb.ThemeHook.default_header_nav())
|
||||
|> assign(:footer_nav_items, BerrypodWeb.ThemeHook.default_footer_nav())
|
||||
|> 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)
|
||||
@@ -853,4 +853,15 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|
||||
|> 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
|
||||
|
||||
Reference in New Issue
Block a user