berrypod/lib/berrypod_web/live/admin/navigation.ex
jamey ae6cf209aa complete admin CSS refactor: delete utilities.css, add layout primitives
- Delete utilities.css (701 lines / 24 KB of Tailwind utility clones)
- Add layout.css with admin-stack, admin-row, admin-cluster, admin-grid
  primitives and gap variants (sm, md, lg, xl)
- Add transitions.css import and layout.css import to admin.css entry point
- Replace all Tailwind utility classes across 26 admin templates with
  semantic admin-*/theme-*/page-specific CSS classes
- Replace all non-dynamic inline styles with semantic classes
- Add ~100 new semantic classes to components.css (analytics, dashboard,
  order detail, settings, theme editor, generic utilities)
- Fix stray text-error → admin-text-error in media.ex
- Add missing .truncate definition to admin CSS
- Only remaining inline styles are dynamic data values (progress bars,
  chart dimensions) and one JS.toggle target

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:40:21 +00:00

294 lines
9.2 KiB
Elixir

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>
<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}
custom_pages={@custom_pages}
/>
<.nav_section
title="Footer navigation"
section="footer"
items={@footer_items}
custom_pages={@custom_pages}
/>
<div class="admin-row admin-row-lg">
<button
phx-click="save"
class="admin-btn admin-btn-primary"
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="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>Path</span>
</div>
<div
:for={{item, idx} <- Enum.with_index(@items)}
class="nav-editor-item"
>
<div class="nav-editor-fields">
<label class="sr-only" for={"nav-#{@section}-#{idx}-label"}>Label</label>
<input
type="text"
id={"nav-#{@section}-#{idx}-label"}
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"
/>
<label class="sr-only" for={"nav-#{@section}-#{idx}-href"}>Path</label>
<input
type="text"
id={"nav-#{@section}-#{idx}-href"}
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="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 :if={@custom_pages != []} class="nav-editor-dropdown-wrap" 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="nav-editor-dropdown-slug">
/{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