add data-driven navigation with admin editor
All checks were successful
deploy / deploy (push) Successful in 1m34s
All checks were successful
deploy / deploy (push) Successful in 1m34s
Replace hardcoded header, footer and mobile nav with settings-driven loops. Nav items stored as JSON via Settings, loaded in ThemeHook with sensible defaults. New admin navigation editor at /admin/navigation for add/remove/reorder/save/reset. Mobile bottom nav also driven from header nav items with icon mapping by slug. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
281
lib/berrypod_web/live/admin/navigation.ex
Normal file
281
lib/berrypod_web/live/admin/navigation.ex
Normal file
@@ -0,0 +1,281 @@
|
||||
defmodule BerrypodWeb.Admin.Navigation do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.{Pages, Settings}
|
||||
alias BerrypodWeb.ThemeHook
|
||||
|
||||
@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())
|
||||
custom_pages = Pages.list_custom_pages()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Navigation")
|
||||
|> assign(:header_items, header_items)
|
||||
|> assign(:footer_items, footer_items)
|
||||
|> assign(:custom_pages, custom_pages)
|
||||
|> assign(:dirty, false)}
|
||||
end
|
||||
|
||||
# ── Item manipulation ──────────────────────────────────────────
|
||||
|
||||
@impl true
|
||||
def handle_event("update_item", params, socket) do
|
||||
section = params["section"]
|
||||
index = String.to_integer(params["index"])
|
||||
field = params["field"]
|
||||
value = params["value"]
|
||||
|
||||
items = get_items(socket, section)
|
||||
item = Enum.at(items, index)
|
||||
|
||||
if item do
|
||||
updated = Map.put(item, field, value)
|
||||
items = List.replace_at(items, index, updated)
|
||||
{:noreply, put_items(socket, section, items)}
|
||||
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)}
|
||||
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)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("add_item", %{"section" => section}, socket) do
|
||||
items = get_items(socket, section) ++ [%{"label" => "", "href" => ""}]
|
||||
{:noreply, put_items(socket, section, items)}
|
||||
end
|
||||
|
||||
def handle_event("add_page", %{"section" => section, "slug" => slug}, socket) do
|
||||
page = Enum.find(socket.assigns.custom_pages, &(&1.slug == slug))
|
||||
|
||||
if page do
|
||||
item = %{"label" => page.title, "href" => "/#{page.slug}", "slug" => page.slug}
|
||||
items = get_items(socket, section) ++ [item]
|
||||
{:noreply, put_items(socket, section, items)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# ── Save / reset ───────────────────────────────────────────────
|
||||
|
||||
def handle_event("save", _params, socket) 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)
|
||||
|> put_flash(:info, "Navigation saved")}
|
||||
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)}
|
||||
end
|
||||
|
||||
# ── Render ─────────────────────────────────────────────────────
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
Navigation
|
||||
<:subtitle>Configure the links in your shop header and footer.</:subtitle>
|
||||
</.header>
|
||||
|
||||
<p :if={@dirty} class="admin-badge admin-badge-warning mt-4">
|
||||
Unsaved changes
|
||||
</p>
|
||||
|
||||
<div class="mt-6 space-y-8" style="max-width: 40rem;">
|
||||
<.nav_section
|
||||
title="Header navigation"
|
||||
section="header"
|
||||
items={@header_items}
|
||||
custom_pages={@custom_pages}
|
||||
/>
|
||||
|
||||
<.nav_section
|
||||
title="Footer navigation"
|
||||
section="footer"
|
||||
items={@footer_items}
|
||||
custom_pages={@custom_pages}
|
||||
/>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
phx-click="save"
|
||||
class={["admin-btn admin-btn-primary", !@dirty && "opacity-50"]}
|
||||
disabled={!@dirty}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<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>
|
||||
"""
|
||||
end
|
||||
|
||||
defp nav_section(assigns) do
|
||||
~H"""
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wider text-base-content/50 mb-3">
|
||||
{@title}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
:for={{item, idx} <- Enum.with_index(@items)}
|
||||
class="nav-editor-item"
|
||||
>
|
||||
<div class="nav-editor-fields">
|
||||
<input
|
||||
type="text"
|
||||
value={item["label"]}
|
||||
placeholder="Label"
|
||||
phx-blur="update_item"
|
||||
phx-value-section={@section}
|
||||
phx-value-index={idx}
|
||||
phx-value-field="label"
|
||||
class="admin-input nav-editor-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item["href"]}
|
||||
placeholder="/path"
|
||||
phx-blur="update_item"
|
||||
phx-value-section={@section}
|
||||
phx-value-index={idx}
|
||||
phx-value-field="href"
|
||||
class="admin-input nav-editor-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="nav-editor-actions">
|
||||
<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
|
||||
phx-click="move_item"
|
||||
phx-value-section={@section}
|
||||
phx-value-index={idx}
|
||||
phx-value-dir="down"
|
||||
disabled={idx == length(@items) - 1}
|
||||
class="admin-btn admin-btn-xs admin-btn-ghost"
|
||||
aria-label="Move down"
|
||||
>
|
||||
<.icon name="hero-chevron-down" class="size-4" />
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :if={@items == []} class="text-sm text-base-content/50 py-4">
|
||||
No items yet.
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<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 :if={@custom_pages != []} class="relative" id={"add-page-#{@section}"}>
|
||||
<button
|
||||
phx-click={Phoenix.LiveView.JS.toggle(to: "#page-menu-#{@section}")}
|
||||
class="admin-btn admin-btn-sm admin-btn-outline"
|
||||
>
|
||||
<.icon name="hero-document-plus" class="size-4" /> Add page
|
||||
</button>
|
||||
<div
|
||||
id={"page-menu-#{@section}"}
|
||||
class="nav-editor-dropdown"
|
||||
style="display: none;"
|
||||
>
|
||||
<button
|
||||
:for={page <- @custom_pages}
|
||||
phx-click="add_page"
|
||||
phx-value-section={@section}
|
||||
phx-value-slug={page.slug}
|
||||
class="nav-editor-dropdown-item"
|
||||
>
|
||||
{page.title}
|
||||
<span class="text-xs text-base-content/40">/{page.slug}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
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
|
||||
@@ -425,6 +425,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())
|
||||
|> preview_page_context(assigns.slug)
|
||||
|
||||
extra = Pages.load_block_data(page.blocks, preview)
|
||||
|
||||
@@ -399,7 +399,9 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
||||
categories: assigns.preview_data.categories,
|
||||
cart_items: PreviewData.cart_drawer_items(),
|
||||
cart_count: 2,
|
||||
cart_subtotal: "£72.00"
|
||||
cart_subtotal: "£72.00",
|
||||
header_nav_items: BerrypodWeb.ThemeHook.default_header_nav(),
|
||||
footer_nav_items: BerrypodWeb.ThemeHook.default_footer_nav()
|
||||
})
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user