implement unified on-site editor phases 1-2
All checks were successful
deploy / deploy (push) Successful in 1m10s

Add theme editing to the existing PageEditorHook, enabling on-site
theme customisation alongside page editing. The editor panel now has
three tabs (Page, Theme, Settings) and can be collapsed while
keeping editing state intact.

- Add theme editing state and event handlers to PageEditorHook
- Add 3-tab UI with tab switching logic
- Add transparent overlay for click-outside dismiss
- Add mobile drag-to-resize with height persistence
- Fix animation replay on drag release (has-dragged class)
- Preserve panel height across LiveView re-renders
- Default to Page tab on editable pages, Theme otherwise
- Show unsaved changes indicator on FAB when panel collapsed
- Fix handle_event grouping warning in admin theme

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-03-09 09:01:21 +00:00
parent 74ab6411f7
commit 168b6ce76f
10 changed files with 954 additions and 53 deletions

View File

@@ -48,10 +48,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
# Keys accepted by shop_layout — used by layout_assigns/1 so page templates
# can spread assigns without listing each one explicitly.
@layout_keys ~w(theme_settings site_name logo_image header_image mode cart_items cart_count
@layout_keys ~w(theme_settings generated_css site_name logo_image header_image mode cart_items cart_count
cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
search_query search_results search_open categories shipping_estimate
country_code available_countries editing editor_current_path editor_sidebar_open
country_code available_countries editing theme_editing editor_current_path editor_sidebar_open
editor_active_tab editor_sheet_state editor_dirty editor_save_status
header_nav_items footer_nav_items newsletter_enabled newsletter_state stripe_connected)a
@doc """
@@ -102,6 +103,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :newsletter_enabled, :boolean, default: false
attr :newsletter_state, :atom, default: :idle
attr :stripe_connected, :boolean, default: true
attr :generated_css, :string, default: nil
slot :inner_block, required: true
@@ -110,9 +112,24 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<div
id={unless @error_page, do: "shop-container"}
phx-hook={unless @error_page, do: "CartPersist"}
class="shop-container"
class="shop-container themed"
data-bottom-nav={!@error_page || nil}
data-mood={@theme_settings.mood}
data-typography={@theme_settings.typography}
data-shape={@theme_settings.shape}
data-density={@theme_settings.density}
data-grid={@theme_settings.grid_columns}
data-header={@theme_settings.header_layout}
data-sticky={to_string(@theme_settings.sticky_header)}
data-layout={@theme_settings.layout_width}
data-shadow={@theme_settings.card_shadow}
data-button-style={@theme_settings.button_style}
>
<%!-- Live-updatable theme CSS (overrides static version in head) --%>
<%= if @generated_css do %>
{Phoenix.HTML.raw("<style id=\"theme-css-live\">#{@generated_css}</style>")}
<% end %>
<.skip_link />
<%= if @theme_settings.announcement_bar do %>
@@ -1032,7 +1049,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
# ── Editor sheet ────────────────────────────────────────────────────
@doc """
Renders the unified editor sheet for page editing.
Renders the unified editor sheet for page/theme/settings editing.
The sheet is anchored to the bottom edge on mobile (<768px) and the right edge
on desktop (≥768px). It has three states on mobile (collapsed, partial, full)
@@ -1040,59 +1057,98 @@ defmodule BerrypodWeb.ShopComponents.Layout do
## Attributes
* `editing` - Whether edit mode is active.
* `editor_dirty` - Whether there are unsaved changes.
* `editing` - Whether page edit mode is active.
* `theme_editing` - Whether theme edit mode is active.
* `editor_dirty` - Whether there are unsaved page changes.
* `editor_sheet_state` - Current state (:collapsed, :partial, :full, or :open).
* `editor_active_tab` - Current tab (:page, :theme, :settings).
* `has_editable_page` - Whether the current page has editable blocks.
## Slots
* `inner_block` - The editor content (block list, settings, etc.).
"""
attr :editing, :boolean, default: false
attr :theme_editing, :boolean, default: false
attr :editor_dirty, :boolean, default: false
attr :editor_sheet_state, :atom, default: :collapsed
attr :editor_save_status, :atom, default: :idle
attr :editor_active_tab, :atom, default: :page
attr :has_editable_page, :boolean, default: false
slot :inner_block
def editor_sheet(assigns) do
# Determine panel title based on active tab
title =
case assigns.editor_active_tab do
:page -> "Page"
:theme -> "Theme"
:settings -> "Settings"
end
# Any editing mode active
any_editing = assigns.editing || assigns.theme_editing
assigns =
assigns
|> assign(:title, title)
|> assign(:any_editing, any_editing)
~H"""
<%!-- Floating action button: always visible when panel is closed --%>
<button
:if={@editor_sheet_state == :collapsed}
type="button"
phx-click={if @editing, do: "editor_set_sheet_state", else: "editor_toggle_editing"}
phx-value-state={if @editing, do: "open", else: nil}
phx-click={if @any_editing, do: "editor_set_sheet_state", else: "editor_set_tab"}
phx-value-state={if @any_editing, do: "open", else: nil}
phx-value-tab={
if @any_editing, do: nil, else: if(@has_editable_page, do: "page", else: "theme")
}
class="editor-fab"
aria-label={if @editing, do: "Show editor", else: "Edit page"}
aria-label={if @any_editing, do: "Show editor", else: "Edit"}
>
<.edit_pencil_svg />
<span>{if @editing, do: "Show editor", else: "Edit page"}</span>
<span>{if @any_editing, do: "Show editor", else: "Edit"}</span>
<span :if={@editing && @editor_dirty} class="editor-fab-dirty" aria-label="Unsaved changes" />
</button>
<%!-- Overlay to catch taps outside the panel --%>
<div
:if={@editor_sheet_state == :open}
class="editor-overlay"
phx-click="editor_set_sheet_state"
phx-value-state="collapsed"
aria-hidden="true"
/>
<%!-- Editor panel: slides in/out --%>
<aside
id="editor-panel"
class="editor-panel"
role="region"
aria-label="Page editor"
aria-label="Site editor"
aria-hidden={to_string(@editor_sheet_state == :collapsed)}
data-state={@editor_sheet_state}
data-editing={to_string(@editing)}
data-editing={to_string(@any_editing)}
phx-hook="EditorSheet"
>
<%!-- Drag handle for mobile resizing --%>
<div class="editor-panel-drag-handle" data-drag-handle>
<div class="editor-panel-drag-handle-bar" />
</div>
<div class="editor-panel-header">
<div class="editor-panel-header-left">
<span class="editor-panel-title">Page editor</span>
<span :if={@editor_dirty} class="editor-panel-dirty" aria-live="polite">
<span class="editor-panel-title">{@title}</span>
<span :if={@editing && @editor_dirty} class="editor-panel-dirty" aria-live="polite">
<span class="editor-panel-dirty-dot" aria-hidden="true" />
<span>Unsaved</span>
</span>
</div>
<div class="editor-panel-header-actions">
<button
:if={@editor_save_status == :saved}
:if={@editor_active_tab == :page && @editor_save_status == :saved}
type="button"
class="admin-btn admin-btn-sm admin-btn-ghost"
disabled
@@ -1100,7 +1156,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
Saved ✓
</button>
<button
:if={@editor_save_status != :saved}
:if={@editor_active_tab == :page && @editor_save_status != :saved}
type="button"
phx-click="editor_save"
class={["admin-btn admin-btn-sm", @editor_dirty && "admin-btn-primary"]}
@@ -1108,9 +1164,66 @@ defmodule BerrypodWeb.ShopComponents.Layout do
>
Save
</button>
<button
type="button"
phx-click="editor_set_sheet_state"
phx-value-state="collapsed"
class="editor-panel-close"
aria-label="Close editor"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
width="20"
height="20"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<%!-- Tab bar --%>
<div class="editor-tabs" role="tablist">
<button
type="button"
role="tab"
phx-click="editor_set_tab"
phx-value-tab="page"
class={["editor-tab", @editor_active_tab == :page && "editor-tab-active"]}
aria-selected={to_string(@editor_active_tab == :page)}
disabled={!@has_editable_page}
title={
if @has_editable_page, do: "Edit page blocks", else: "This page has no editable blocks"
}
>
Page
</button>
<button
type="button"
role="tab"
phx-click="editor_set_tab"
phx-value-tab="theme"
class={["editor-tab", @editor_active_tab == :theme && "editor-tab-active"]}
aria-selected={to_string(@editor_active_tab == :theme)}
>
Theme
</button>
<button
type="button"
role="tab"
phx-click="editor_set_tab"
phx-value-tab="settings"
class={["editor-tab", @editor_active_tab == :settings && "editor-tab-active"]}
aria-selected={to_string(@editor_active_tab == :settings)}
>
Settings
</button>
</div>
<div class="editor-panel-content">
{render_slot(@inner_block)}
</div>