add admin UX polish: nav grouping, inline settings, real preview data
All checks were successful
deploy / deploy (push) Successful in 1m33s

- sidebar nav grouped under Shop/Content/Settings section headers with
  subtle uppercase labels (#105)
- custom page settings now show inline in a collapsible panel within the
  editor instead of navigating away to a separate page (#107)
- admin editor preview loads real products and categories from the DB,
  falling back to PreviewData only on fresh installs (#108)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-28 20:17:03 +00:00
parent 32cd642110
commit 0a7982dfe8
4 changed files with 271 additions and 106 deletions

View File

@ -94,6 +94,22 @@
/* ── Sidebar nav ── */ /* ── Sidebar nav ── */
.admin-nav-group {
& + & {
margin-top: 0.75rem;
}
}
.admin-nav-heading {
display: block;
padding: 0.25rem 0.75rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: color-mix(in oklch, var(--t-text-primary) 45%, transparent);
}
.admin-nav { .admin-nav {
list-style: none; list-style: none;
padding: 0; padding: 0;
@ -171,6 +187,10 @@
&:hover { background-color: var(--t-surface-sunken); opacity: 1; } &:hover { background-color: var(--t-surface-sunken); opacity: 1; }
} }
.admin-btn-active {
background-color: var(--t-surface-sunken);
}
.admin-btn-outline { .admin-btn-outline {
background: none; background: none;
color: inherit; color: inherit;
@ -1282,6 +1302,39 @@
color: var(--t-text-secondary); color: var(--t-text-secondary);
} }
/* Inline page settings panel */
.page-settings-panel {
margin-top: 1rem;
padding: 1rem;
border: 1px solid var(--t-border-default);
border-radius: 0.5rem;
background: var(--t-surface-sunken);
}
.page-settings-fields {
display: grid;
gap: 0.75rem;
@media (min-width: 40em) {
grid-template-columns: 1fr 1fr;
}
}
.page-settings-row {
display: flex;
gap: 1.5rem;
align-items: center;
}
.page-settings-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--t-border-default);
}
/* Page editor split layout */ /* Page editor split layout */
.page-editor-container { .page-editor-container {

View File

@ -53,104 +53,119 @@
<%!-- nav links --%> <%!-- nav links --%>
<nav class="flex-1 p-2" aria-label="Admin navigation"> <nav class="flex-1 p-2" aria-label="Admin navigation">
<ul class="admin-nav"> <div class="admin-nav-group">
<li> <span class="admin-nav-heading">Shop</span>
<.link <ul class="admin-nav">
navigate={~p"/admin"} <li>
class={admin_nav_active?(@current_path, "/admin")} <.link
> navigate={~p"/admin"}
<.icon name="hero-home" class="size-5" /> Dashboard class={admin_nav_active?(@current_path, "/admin")}
</.link> >
</li> <.icon name="hero-home" class="size-5" /> Dashboard
<li> </.link>
<.link </li>
navigate={~p"/admin/analytics"} <li>
class={admin_nav_active?(@current_path, "/admin/analytics")} <.link
> navigate={~p"/admin/analytics"}
<.icon name="hero-chart-bar" class="size-5" /> Analytics class={admin_nav_active?(@current_path, "/admin/analytics")}
</.link> >
</li> <.icon name="hero-chart-bar" class="size-5" /> Analytics
<li> </.link>
<.link </li>
navigate={~p"/admin/orders"} <li>
class={admin_nav_active?(@current_path, "/admin/orders")} <.link
> navigate={~p"/admin/orders"}
<.icon name="hero-shopping-bag" class="size-5" /> Orders class={admin_nav_active?(@current_path, "/admin/orders")}
</.link> >
</li> <.icon name="hero-shopping-bag" class="size-5" /> Orders
<li> </.link>
<.link </li>
navigate={~p"/admin/products"} <li>
class={admin_nav_active?(@current_path, "/admin/products")} <.link
> navigate={~p"/admin/products"}
<.icon name="hero-cube" class="size-5" /> Products class={admin_nav_active?(@current_path, "/admin/products")}
</.link> >
</li> <.icon name="hero-cube" class="size-5" /> Products
<li> </.link>
<.link </li>
navigate={~p"/admin/providers"} <li>
class={admin_nav_active?(@current_path, "/admin/providers")} <.link
> navigate={~p"/admin/providers"}
<.icon name="hero-link" class="size-5" /> Print providers class={admin_nav_active?(@current_path, "/admin/providers")}
</.link> >
</li> <.icon name="hero-link" class="size-5" /> Print providers
<li> </.link>
<.link </li>
navigate={~p"/admin/pages"} </ul>
class={admin_nav_active?(@current_path, "/admin/pages")} </div>
>
<.icon name="hero-document" class="size-5" /> Pages <div class="admin-nav-group">
</.link> <span class="admin-nav-heading">Content</span>
</li> <ul class="admin-nav">
<li> <li>
<.link <.link
navigate={~p"/admin/navigation"} navigate={~p"/admin/pages"}
class={admin_nav_active?(@current_path, "/admin/navigation")} class={admin_nav_active?(@current_path, "/admin/pages")}
> >
<.icon name="hero-bars-3" class="size-5" /> Navigation <.icon name="hero-document" class="size-5" /> Pages
</.link> </.link>
</li> </li>
<li> <li>
<.link <.link
navigate={~p"/admin/media"} navigate={~p"/admin/navigation"}
class={admin_nav_active?(@current_path, "/admin/media")} class={admin_nav_active?(@current_path, "/admin/navigation")}
> >
<.icon name="hero-photo" class="size-5" /> Media <.icon name="hero-bars-3" class="size-5" /> Navigation
</.link> </.link>
</li> </li>
<li> <li>
<.link <.link
href={~p"/admin/theme"} navigate={~p"/admin/media"}
class={admin_nav_active?(@current_path, "/admin/theme")} class={admin_nav_active?(@current_path, "/admin/media")}
> >
<.icon name="hero-paint-brush" class="size-5" /> Theme <.icon name="hero-photo" class="size-5" /> Media
</.link> </.link>
</li> </li>
<li> <li>
<.link <.link
navigate={~p"/admin/settings"} href={~p"/admin/theme"}
class={admin_nav_active?(@current_path, "/admin/settings")} class={admin_nav_active?(@current_path, "/admin/theme")}
> >
<.icon name="hero-cog-6-tooth" class="size-5" /> Settings <.icon name="hero-paint-brush" class="size-5" /> Theme
</.link> </.link>
</li> </li>
<li> </ul>
<.link </div>
navigate={~p"/admin/settings/email"}
class={admin_nav_active?(@current_path, "/admin/settings/email")} <div class="admin-nav-group">
> <span class="admin-nav-heading">Settings</span>
<.icon name="hero-envelope" class="size-5" /> Email <ul class="admin-nav">
</.link> <li>
</li> <.link
<li> navigate={~p"/admin/settings"}
<.link class={admin_nav_active?(@current_path, "/admin/settings")}
navigate={~p"/admin/redirects"} >
class={admin_nav_active?(@current_path, "/admin/redirects")} <.icon name="hero-cog-6-tooth" class="size-5" /> Settings
> </.link>
<.icon name="hero-arrow-uturn-right" class="size-5" /> Redirects </li>
</.link> <li>
</li> <.link
</ul> navigate={~p"/admin/settings/email"}
class={admin_nav_active?(@current_path, "/admin/settings/email")}
>
<.icon name="hero-envelope" class="size-5" /> Email
</.link>
</li>
<li>
<.link
navigate={~p"/admin/redirects"}
class={admin_nav_active?(@current_path, "/admin/redirects")}
>
<.icon name="hero-arrow-uturn-right" class="size-5" /> Redirects
</.link>
</li>
</ul>
</div>
</nav> </nav>
<%!-- sidebar footer --%> <%!-- sidebar footer --%>

View File

@ -1,7 +1,7 @@
defmodule BerrypodWeb.Admin.Pages.Editor do defmodule BerrypodWeb.Admin.Pages.Editor do
use BerrypodWeb, :live_view use BerrypodWeb, :live_view
alias Berrypod.{LegalPages, Media, Pages} alias Berrypod.{LegalPages, Media, Pages, Products}
alias Berrypod.Pages.{BlockEditor, BlockTypes, Page} alias Berrypod.Pages.{BlockEditor, BlockTypes, Page}
alias Berrypod.Products.ProductImage alias Berrypod.Products.ProductImage
alias Berrypod.Theme.{Fonts, PreviewData} alias Berrypod.Theme.{Fonts, PreviewData}
@ -13,9 +13,12 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
page = Pages.get_page(slug) page = Pages.get_page(slug)
allowed_blocks = BlockTypes.allowed_for(slug) allowed_blocks = BlockTypes.allowed_for(slug)
real_products = Products.list_visible_products(limit: 8)
real_categories = Products.list_categories()
preview_data = %{ preview_data = %{
products: PreviewData.products(), products: if(real_products != [], do: real_products, else: PreviewData.products()),
categories: PreviewData.categories(), categories: if(real_categories != [], do: real_categories, else: PreviewData.categories()),
cart_items: PreviewData.cart_items() cart_items: PreviewData.cart_items()
} }
@ -43,6 +46,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
|> assign(:image_picker_search, "") |> assign(:image_picker_search, "")
|> assign(:is_custom_page, !Page.system_slug?(slug)) |> assign(:is_custom_page, !Page.system_slug?(slug))
|> assign(:is_legal_page, LegalPages.legal_slug?(slug)) |> assign(:is_legal_page, LegalPages.legal_slug?(slug))
|> assign_settings_form(slug)
|> allow_upload(:image_picker_upload, |> allow_upload(:image_picker_upload,
accept: ~w(.png .jpg .jpeg .webp .svg), accept: ~w(.png .jpg .jpeg .webp .svg),
max_entries: 1, max_entries: 1,
@ -462,6 +466,55 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
end end
end end
# ── Page settings (custom pages only) ─────────────────────────────
def handle_event("toggle_settings", _params, socket) do
{:noreply, assign(socket, :show_settings, !socket.assigns.show_settings)}
end
def handle_event("validate_settings", %{"page" => params}, socket) do
form =
socket.assigns.page_struct
|> Page.custom_changeset(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, :settings_form, form)}
end
def handle_event("save_settings", %{"page" => params}, socket) do
case Pages.update_custom_page(socket.assigns.page_struct, params) do
{:ok, page} ->
{:noreply,
socket
|> assign(:page_struct, page)
|> assign(:page_data, %{socket.assigns.page_data | title: page.title})
|> assign(:page_title, page.title)
|> assign(:settings_form, to_form(Page.custom_changeset(page, %{})))
|> assign(:show_settings, false)
|> put_flash(:info, "Page settings saved")}
{:error, changeset} ->
{:noreply, assign(socket, :settings_form, to_form(changeset))}
end
end
defp assign_settings_form(socket, slug) do
if Page.system_slug?(slug) do
socket
|> assign(:show_settings, false)
|> assign(:page_struct, nil)
|> assign(:settings_form, nil)
else
page_struct = Pages.get_page_struct(slug)
socket
|> assign(:show_settings, false)
|> assign(:page_struct, page_struct)
|> assign(:settings_form, to_form(Page.custom_changeset(page_struct, %{})))
end
end
# ── Render ─────────────────────────────────────────────────────── # ── Render ───────────────────────────────────────────────────────
@impl true @impl true
@ -487,13 +540,16 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
/> />
{if @show_preview, do: "Edit", else: "Preview"} {if @show_preview, do: "Edit", else: "Preview"}
</button> </button>
<.link <button
:if={@is_custom_page} :if={@is_custom_page}
navigate={~p"/admin/pages/#{@slug}/settings"} phx-click="toggle_settings"
class="admin-btn admin-btn-sm admin-btn-ghost" class={[
"admin-btn admin-btn-sm admin-btn-ghost",
@show_settings && "admin-btn-active"
]}
> >
<.icon name="hero-cog-6-tooth" class="size-4" /> Settings <.icon name="hero-cog-6-tooth" class="size-4" /> Settings
</.link> </button>
<button <button
phx-click="undo" phx-click="undo"
class={["admin-btn admin-btn-sm admin-btn-ghost", @history == [] && "opacity-30"]} class={["admin-btn admin-btn-sm admin-btn-ghost", @history == [] && "opacity-30"]}
@ -554,6 +610,47 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
</p> </p>
</div> </div>
<%!-- Inline page settings (custom pages) --%>
<div :if={@show_settings && @settings_form} class="page-settings-panel">
<.form
for={@settings_form}
id="inline-page-settings"
phx-change="validate_settings"
phx-submit="save_settings"
>
<div class="page-settings-fields">
<.input field={@settings_form[:title]} label="Title" />
<.input field={@settings_form[:slug]} label="URL slug" />
<.input
field={@settings_form[:meta_description]}
type="textarea"
label="Meta description"
phx-no-feedback
/>
<div class="page-settings-row">
<.input field={@settings_form[:published]} type="checkbox" label="Published" />
<.input
field={@settings_form[:show_in_nav]}
type="checkbox"
label="Show in navigation"
/>
</div>
</div>
<div class="page-settings-actions">
<.button type="submit" phx-disable-with="Saving..." class="admin-btn-sm">
Save settings
</.button>
<button
type="button"
phx-click="toggle_settings"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
Cancel
</button>
</div>
</.form>
</div>
<div class="page-editor-container"> <div class="page-editor-container">
<%!-- Editor pane --%> <%!-- Editor pane --%>
<div class={[ <div class={[

View File

@ -987,7 +987,7 @@ defmodule BerrypodWeb.Admin.PagesTest do
test "shows settings button for custom pages", %{conn: conn} do test "shows settings button for custom pages", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/pages/size-guide") {:ok, view, _html} = live(conn, ~p"/admin/pages/size-guide")
assert has_element?(view, "a[href='/admin/pages/size-guide/settings']", "Settings") assert has_element?(view, "button", "Settings")
end end
test "does not show reset to defaults for custom pages", %{conn: conn} do test "does not show reset to defaults for custom pages", %{conn: conn} do