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

@ -87,8 +87,8 @@ Extend the existing page editor (PageEditorHook + editor_sheet) to include theme
| Phase | Description | Est | Status | | Phase | Description | Est | Status |
|---|---|---|---| |---|---|---|---|
| 1 | Add theme editing state to PageEditorHook | 2h | planned | | 1 | Add theme editing state to PageEditorHook | 2h | done |
| 2 | Add 3-tab UI to editor panel (Page/Theme/Settings) | 2h | planned | | 2 | Add 3-tab UI to editor panel (Page/Theme/Settings) | 2h | done |
| 3 | Extract theme editor into reusable component | 3h | planned | | 3 | Extract theme editor into reusable component | 3h | planned |
| 3b | Create settings editor component | 2h | planned | | 3b | Create settings editor component | 2h | planned |
| 4 | Image upload handling in hook context | 2h | planned | | 4 | Image upload handling in hook context | 2h | planned |

View File

@ -2525,6 +2525,15 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Editor overlay ── */
.editor-overlay {
position: fixed;
inset: 0;
z-index: 999;
background: transparent;
cursor: pointer;
}
/* ── Editor panel ── */ /* ── Editor panel ── */
.editor-panel { .editor-panel {
position: fixed; position: fixed;
@ -2533,6 +2542,10 @@
flex-direction: column; flex-direction: column;
background: var(--t-surface-base); background: var(--t-surface-base);
box-shadow: var(--t-shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15)); box-shadow: var(--t-shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15));
/* Force GPU compositing to prevent paint flickering during drag */
transform: translateZ(0);
backface-visibility: hidden;
isolation: isolate;
} }
/* Hidden when collapsed (but not during close animation) */ /* Hidden when collapsed (but not during close animation) */
@ -2540,6 +2553,34 @@
display: none; display: none;
} }
/* ── Drag handle (mobile only) ── */
.editor-panel-drag-handle {
display: none;
}
@media (max-width: 767px) {
.editor-panel-drag-handle {
display: flex;
justify-content: center;
align-items: center;
padding: 0.5rem;
cursor: grab;
touch-action: none;
flex-shrink: 0;
}
.editor-panel-drag-handle:active {
cursor: grabbing;
}
.editor-panel-drag-handle-bar {
width: 2.5rem;
height: 4px;
border-radius: 2px;
background: var(--t-border-default);
}
}
/* ── Mobile: bottom sheet ── */ /* ── Mobile: bottom sheet ── */
@media (max-width: 767px) { @media (max-width: 767px) {
.editor-panel[data-state="open"], .editor-panel[data-state="open"],
@ -2547,13 +2588,26 @@
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
top: 15dvh; /* Default 50vh, overridden by JS with saved preference */
height: var(--editor-panel-height, 50dvh);
max-height: 90dvh;
min-height: 200px;
border-radius: var(--t-radius-lg, 12px) var(--t-radius-lg, 12px) 0 0; border-radius: var(--t-radius-lg, 12px) var(--t-radius-lg, 12px) 0 0;
border: 1px solid var(--t-border-default); border: 1px solid var(--t-border-default);
border-bottom: none; border-bottom: none;
/* Keep layer promoted to prevent flash on resize end */
will-change: transform;
contain: strict;
} }
.editor-panel[data-state="open"] { /* During drag: disable animations and also hint height changes */
.editor-panel.dragging {
transition: none !important;
will-change: transform, height;
}
/* Only animate on initial open, not after drag ends */
.editor-panel[data-state="open"]:not(.dragging):not(.has-dragged) {
animation: editor-panel-slide-up 0.3s cubic-bezier(0.32, 0.72, 0, 1) both; animation: editor-panel-slide-up 0.3s cubic-bezier(0.32, 0.72, 0, 1) both;
} }
@ -2640,10 +2694,30 @@
.editor-panel-header-actions { .editor-panel-header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.5rem;
flex-shrink: 0; flex-shrink: 0;
} }
.editor-panel-close {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
border-radius: 0.375rem;
background: transparent;
color: var(--t-text-secondary);
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
&:hover {
background: var(--t-border-default);
color: var(--t-text-primary);
}
}
/* ── Dirty indicator ── */ /* ── Dirty indicator ── */
.editor-panel-dirty { .editor-panel-dirty {
display: flex; display: flex;
@ -2661,12 +2735,50 @@
background: currentColor; background: currentColor;
} }
/* ── Tab bar ── */
.editor-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--t-border-default);
padding: 0 0.75rem;
flex-shrink: 0;
}
.editor-tab {
flex: 1;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--t-text-secondary);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
&:hover:not(:disabled) {
color: var(--t-text-primary);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.editor-tab-active {
color: var(--t-text-primary);
border-bottom-color: var(--t-accent, oklch(0.6 0.15 250));
}
/* ── Panel content ── */ /* ── Panel content ── */
.editor-panel-content { .editor-panel-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
padding: 0.75rem; padding: 0.75rem;
/* Ensure content is clipped to panel bounds during drag */
contain: paint;
@media (min-width: 768px) { @media (min-width: 768px) {
padding: 1rem; padding: 1rem;

View File

@ -679,18 +679,10 @@ const DirtyGuard = {
} }
} }
// EditorSheet: handles click-outside and Escape for the editor panel // EditorSheet: handles click-outside, Escape, and mobile drag-to-resize
const EditorSheet = { const EditorSheet = {
mounted() { mounted() {
this._onDocMousedown = (e) => { // Close on Escape key
if (this._getState() !== "open") return
const fab = document.querySelector(".editor-fab")
if (this.el.contains(e.target)) return
if (fab && fab.contains(e.target)) return
this._close()
}
document.addEventListener("mousedown", this._onDocMousedown)
this._onKeydown = (e) => { this._onKeydown = (e) => {
if (e.key === "Escape" && this._getState() === "open") { if (e.key === "Escape" && this._getState() === "open") {
e.preventDefault() e.preventDefault()
@ -698,11 +690,29 @@ const EditorSheet = {
} }
} }
document.addEventListener("keydown", this._onKeydown) document.addEventListener("keydown", this._onKeydown)
// Restore saved height on mobile and mark as already opened
this._restoreSavedHeight()
this._hasDragged = !!localStorage.getItem("editor-panel-height")
if (this._hasDragged) {
this.el.classList.add("has-dragged")
}
// Set up drag handle for mobile resizing
this._setupDragHandle()
},
updated() {
// Restore state after LiveView re-renders the element
this._restoreSavedHeight()
if (this._hasDragged) {
this.el.classList.add("has-dragged")
}
}, },
destroyed() { destroyed() {
document.removeEventListener("mousedown", this._onDocMousedown)
document.removeEventListener("keydown", this._onKeydown) document.removeEventListener("keydown", this._onKeydown)
this._cleanupDragHandle()
}, },
_getState() { _getState() {
@ -732,6 +742,103 @@ const EditorSheet = {
if (region) { if (region) {
region.textContent = message region.textContent = message
} }
},
// ── Mobile drag-to-resize ──
_isMobile() {
return window.matchMedia("(max-width: 767px)").matches
},
_restoreSavedHeight() {
if (!this._isMobile()) return
const savedHeight = localStorage.getItem("editor-panel-height")
if (savedHeight) {
this.el.style.setProperty("--editor-panel-height", savedHeight)
}
},
_setupDragHandle() {
const handle = this.el.querySelector("[data-drag-handle]")
if (!handle) return
this._dragHandle = handle
this._isDragging = false
this._startY = 0
this._startHeight = 0
// Touch events
this._onTouchStart = (e) => this._handleDragStart(e.touches[0].clientY)
this._onTouchMove = (e) => {
if (!this._isDragging) return
e.preventDefault()
this._handleDragMove(e.touches[0].clientY)
}
this._onTouchEnd = () => this._handleDragEnd()
// Mouse events (for testing on desktop)
this._onMouseDown = (e) => {
e.preventDefault()
this._handleDragStart(e.clientY)
document.addEventListener("mousemove", this._onMouseMove)
document.addEventListener("mouseup", this._onMouseUp)
}
this._onMouseMove = (e) => {
if (!this._isDragging) return
this._handleDragMove(e.clientY)
}
this._onMouseUp = () => {
this._handleDragEnd()
document.removeEventListener("mousemove", this._onMouseMove)
document.removeEventListener("mouseup", this._onMouseUp)
}
handle.addEventListener("touchstart", this._onTouchStart, { passive: true })
document.addEventListener("touchmove", this._onTouchMove, { passive: false })
document.addEventListener("touchend", this._onTouchEnd)
handle.addEventListener("mousedown", this._onMouseDown)
},
_cleanupDragHandle() {
if (!this._dragHandle) return
this._dragHandle.removeEventListener("touchstart", this._onTouchStart)
document.removeEventListener("touchmove", this._onTouchMove)
document.removeEventListener("touchend", this._onTouchEnd)
this._dragHandle.removeEventListener("mousedown", this._onMouseDown)
document.removeEventListener("mousemove", this._onMouseMove)
document.removeEventListener("mouseup", this._onMouseUp)
},
_handleDragStart(clientY) {
if (!this._isMobile()) return
this._isDragging = true
this._startY = clientY
this._startHeight = this.el.offsetHeight
this.el.classList.add("dragging")
},
_handleDragMove(clientY) {
if (!this._isDragging) return
const deltaY = this._startY - clientY
const newHeight = this._startHeight + deltaY
const vh = window.innerHeight
const minHeight = 200
const maxHeight = vh * 0.9
const clampedHeight = Math.max(minHeight, Math.min(newHeight, maxHeight))
this.el.style.setProperty("--editor-panel-height", `${clampedHeight}px`)
},
_handleDragEnd() {
if (!this._isDragging) return
this._isDragging = false
// Save the height preference
const currentHeight = getComputedStyle(this.el).getPropertyValue("--editor-panel-height")
if (currentHeight) {
localStorage.setItem("editor-panel-height", currentHeight.trim())
}
// Mark that we've dragged so the open animation doesn't replay
this._hasDragged = true
this.el.classList.add("has-dragged")
this.el.classList.remove("dragging")
} }
} }

View File

@ -1,6 +1,6 @@
# Unified On-Site Editing Mode # Unified On-Site Editing Mode
Status: Planned Status: In Progress (Phase 1-2 complete)
Replace the separate admin theme editor (`/admin/theme`) with an on-site editing experience. When the admin clicks "Theme" or "Edit page", they're taken to the actual shop where a sliding panel allows them to edit either page content or theme settings — seeing changes live on the real site. Replace the separate admin theme editor (`/admin/theme`) with an on-site editing experience. When the admin clicks "Theme" or "Edit page", they're taken to the actual shop where a sliding panel allows them to edit either page content or theme settings — seeing changes live on the real site.

View File

@ -48,10 +48,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
# Keys accepted by shop_layout — used by layout_assigns/1 so page templates # Keys accepted by shop_layout — used by layout_assigns/1 so page templates
# can spread assigns without listing each one explicitly. # 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 cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
search_query search_results search_open categories shipping_estimate 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 header_nav_items footer_nav_items newsletter_enabled newsletter_state stripe_connected)a
@doc """ @doc """
@ -102,6 +103,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :newsletter_enabled, :boolean, default: false attr :newsletter_enabled, :boolean, default: false
attr :newsletter_state, :atom, default: :idle attr :newsletter_state, :atom, default: :idle
attr :stripe_connected, :boolean, default: true attr :stripe_connected, :boolean, default: true
attr :generated_css, :string, default: nil
slot :inner_block, required: true slot :inner_block, required: true
@ -110,9 +112,24 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<div <div
id={unless @error_page, do: "shop-container"} id={unless @error_page, do: "shop-container"}
phx-hook={unless @error_page, do: "CartPersist"} phx-hook={unless @error_page, do: "CartPersist"}
class="shop-container" class="shop-container themed"
data-bottom-nav={!@error_page || nil} 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 /> <.skip_link />
<%= if @theme_settings.announcement_bar do %> <%= if @theme_settings.announcement_bar do %>
@ -1032,7 +1049,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
# ── Editor sheet ──────────────────────────────────────────────────── # ── Editor sheet ────────────────────────────────────────────────────
@doc """ @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 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) on desktop (768px). It has three states on mobile (collapsed, partial, full)
@ -1040,59 +1057,98 @@ defmodule BerrypodWeb.ShopComponents.Layout do
## Attributes ## Attributes
* `editing` - Whether edit mode is active. * `editing` - Whether page edit mode is active.
* `editor_dirty` - Whether there are unsaved changes. * `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_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 ## Slots
* `inner_block` - The editor content (block list, settings, etc.). * `inner_block` - The editor content (block list, settings, etc.).
""" """
attr :editing, :boolean, default: false attr :editing, :boolean, default: false
attr :theme_editing, :boolean, default: false
attr :editor_dirty, :boolean, default: false attr :editor_dirty, :boolean, default: false
attr :editor_sheet_state, :atom, default: :collapsed attr :editor_sheet_state, :atom, default: :collapsed
attr :editor_save_status, :atom, default: :idle attr :editor_save_status, :atom, default: :idle
attr :editor_active_tab, :atom, default: :page
attr :has_editable_page, :boolean, default: false
slot :inner_block slot :inner_block
def editor_sheet(assigns) do 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""" ~H"""
<%!-- Floating action button: always visible when panel is closed --%> <%!-- Floating action button: always visible when panel is closed --%>
<button <button
:if={@editor_sheet_state == :collapsed} :if={@editor_sheet_state == :collapsed}
type="button" type="button"
phx-click={if @editing, do: "editor_set_sheet_state", else: "editor_toggle_editing"} phx-click={if @any_editing, do: "editor_set_sheet_state", else: "editor_set_tab"}
phx-value-state={if @editing, do: "open", else: nil} 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" 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 /> <.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" /> <span :if={@editing && @editor_dirty} class="editor-fab-dirty" aria-label="Unsaved changes" />
</button> </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 --%> <%!-- Editor panel: slides in/out --%>
<aside <aside
id="editor-panel" id="editor-panel"
class="editor-panel" class="editor-panel"
role="region" role="region"
aria-label="Page editor" aria-label="Site editor"
aria-hidden={to_string(@editor_sheet_state == :collapsed)} aria-hidden={to_string(@editor_sheet_state == :collapsed)}
data-state={@editor_sheet_state} data-state={@editor_sheet_state}
data-editing={to_string(@editing)} data-editing={to_string(@any_editing)}
phx-hook="EditorSheet" 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">
<div class="editor-panel-header-left"> <div class="editor-panel-header-left">
<span class="editor-panel-title">Page editor</span> <span class="editor-panel-title">{@title}</span>
<span :if={@editor_dirty} class="editor-panel-dirty" aria-live="polite"> <span :if={@editing && @editor_dirty} class="editor-panel-dirty" aria-live="polite">
<span class="editor-panel-dirty-dot" aria-hidden="true" /> <span class="editor-panel-dirty-dot" aria-hidden="true" />
<span>Unsaved</span> <span>Unsaved</span>
</span> </span>
</div> </div>
<div class="editor-panel-header-actions"> <div class="editor-panel-header-actions">
<button <button
:if={@editor_save_status == :saved} :if={@editor_active_tab == :page && @editor_save_status == :saved}
type="button" type="button"
class="admin-btn admin-btn-sm admin-btn-ghost" class="admin-btn admin-btn-sm admin-btn-ghost"
disabled disabled
@ -1100,7 +1156,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
Saved Saved
</button> </button>
<button <button
:if={@editor_save_status != :saved} :if={@editor_active_tab == :page && @editor_save_status != :saved}
type="button" type="button"
phx-click="editor_save" phx-click="editor_save"
class={["admin-btn admin-btn-sm", @editor_dirty && "admin-btn-primary"]} class={["admin-btn admin-btn-sm", @editor_dirty && "admin-btn-primary"]}
@ -1108,9 +1164,66 @@ defmodule BerrypodWeb.ShopComponents.Layout do
> >
Save Save
</button> </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>
</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"> <div class="editor-panel-content">
{render_slot(@inner_block)} {render_slot(@inner_block)}
</div> </div>

View File

@ -326,10 +326,6 @@ defmodule BerrypodWeb.Admin.Theme.Index do
end end
end end
defp has_valid_logo?(socket) do
socket.assigns.theme_settings.show_logo && socket.assigns.logo_image != nil
end
@impl true @impl true
def handle_event("save_theme", _params, socket) do def handle_event("save_theme", _params, socket) do
socket = put_flash(socket, :info, "Theme saved successfully") socket = put_flash(socket, :info, "Theme saved successfully")
@ -452,6 +448,10 @@ defmodule BerrypodWeb.Admin.Theme.Index do
{:noreply, socket} {:noreply, socket}
end end
defp has_valid_logo?(socket) do
socket.assigns.theme_settings.show_logo && socket.assigns.logo_image != nil
end
def error_to_string(:too_large), do: "File is too large" def error_to_string(:too_large), do: "File is too large"
def error_to_string(:too_many_files), do: "Too many files" def error_to_string(:too_many_files), do: "Too many files"
def error_to_string(:not_accepted), do: "File type not accepted" def error_to_string(:not_accepted), do: "File type not accepted"

View File

@ -330,8 +330,7 @@
<div class="theme-subfield"> <div class="theme-subfield">
<form phx-change="update_setting" phx-value-field="favicon_short_name"> <form phx-change="update_setting" phx-value-field="favicon_short_name">
<label class="theme-slider-label theme-block-label"> <label class="theme-slider-label theme-block-label">
Short name Short name <span class="admin-text-tertiary">— appears under home screen icon</span>
<span class="admin-text-tertiary">— appears under home screen icon</span>
</label> </label>
<input <input
type="text" type="text"
@ -441,7 +440,7 @@
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Header Image Controls --> <!-- Header Image Controls -->
<div class="admin-stack admin-stack-md theme-subfield"> <div class="admin-stack admin-stack-md theme-subfield">
<form phx-change="update_setting" phx-value-field="header_zoom"> <form phx-change="update_setting" phx-value-field="header_zoom">
@ -521,7 +520,7 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<!-- Presets Section --> <!-- Presets Section -->
<div class="theme-section"> <div class="theme-section">
<label class="theme-section-label">Start with a preset</label> <label class="theme-section-label">Start with a preset</label>

View File

@ -1,13 +1,15 @@
defmodule BerrypodWeb.PageEditorHook do defmodule BerrypodWeb.PageEditorHook do
@moduledoc """ @moduledoc """
LiveView on_mount hook for the live page editor sidebar. LiveView on_mount hook for the unified on-site editor.
Mounted in the public_shop live_session. When an admin visits any shop Mounted in the public_shop live_session. When an admin visits any shop
page with `?edit=true` in the URL, this hook activates editing mode: page, this hook enables editing capabilities:
loads a working copy of the page's blocks, attaches event handlers for
block manipulation, and sets assigns that trigger the editor sidebar
in `PageRenderer.render_page/1`.
1. **Page editing** loads a working copy of the page's blocks, attaches
event handlers for block manipulation
2. **Theme editing** provides live theme customisation on the actual shop
The hook manages a tabbed editor panel with Page, Theme, and Settings tabs.
Non-admin users are unaffected the hook just assigns `editing: false`. Non-admin users are unaffected the hook just assigns `editing: false`.
## Actions ## Actions
@ -18,12 +20,15 @@ defmodule BerrypodWeb.PageEditorHook do
import Phoenix.Component, only: [assign: 3] import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [attach_hook: 4, push_navigate: 2] import Phoenix.LiveView, only: [attach_hook: 4, push_navigate: 2]
alias Berrypod.{Media, Settings}
alias Berrypod.Pages alias Berrypod.Pages
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults} alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
alias Berrypod.Theme.{Contrast, CSSGenerator, Presets}
def on_mount(:mount_page_editor, _params, _session, socket) do def on_mount(:mount_page_editor, _params, _session, socket) do
socket = socket =
socket socket
# Page editing state
|> assign(:editing, false) |> assign(:editing, false)
|> assign(:editing_blocks, nil) |> assign(:editing_blocks, nil)
|> assign(:editor_dirty, false) |> assign(:editor_dirty, false)
@ -43,6 +48,18 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editor_image_picker_images, []) |> assign(:editor_image_picker_images, [])
|> assign(:editor_image_picker_search, "") |> assign(:editor_image_picker_search, "")
|> assign(:editor_save_status, :idle) |> assign(:editor_save_status, :idle)
# Unified editor tab state (:page | :theme | :settings)
|> assign(:editor_active_tab, :page)
# Theme editing state
|> assign(:theme_editing, false)
|> assign(:theme_editor_settings, nil)
|> assign(:theme_editor_active_preset, nil)
|> assign(:theme_editor_logo_image, nil)
|> assign(:theme_editor_header_image, nil)
|> assign(:theme_editor_icon_image, nil)
|> assign(:theme_editor_contrast_warning, :ok)
|> assign(:theme_editor_customise_open, false)
|> assign(:theme_editor_presets, Presets.all_with_descriptions())
|> attach_hook(:editor_params, :handle_params, &handle_editor_params/3) |> attach_hook(:editor_params, :handle_params, &handle_editor_params/3)
|> attach_hook(:editor_events, :handle_event, &handle_editor_event/3) |> attach_hook(:editor_events, :handle_event, &handle_editor_event/3)
|> attach_hook(:editor_info, :handle_info, &handle_editor_info/2) |> attach_hook(:editor_info, :handle_info, &handle_editor_info/2)
@ -84,7 +101,7 @@ defmodule BerrypodWeb.PageEditorHook do
# set_sheet_state can be called even when not editing (from JS click-outside) # set_sheet_state can be called even when not editing (from JS click-outside)
defp handle_editor_event("editor_set_sheet_state", %{"state" => state_str}, socket) do defp handle_editor_event("editor_set_sheet_state", %{"state" => state_str}, socket) do
if socket.assigns.is_admin and socket.assigns[:page] do if socket.assigns.is_admin do
state = if state_str == "open", do: :open, else: :collapsed state = if state_str == "open", do: :open, else: :collapsed
{:halt, assign(socket, :editor_sheet_state, state)} {:halt, assign(socket, :editor_sheet_state, state)}
else else
@ -92,6 +109,62 @@ defmodule BerrypodWeb.PageEditorHook do
end end
end end
# Tab switching for unified editor
defp handle_editor_event("editor_set_tab", %{"tab" => tab_str}, socket) do
if socket.assigns.is_admin do
tab = String.to_existing_atom(tab_str)
socket =
case tab do
:theme ->
# Load theme state if not already loaded
if socket.assigns.theme_editing do
assign(socket, :editor_active_tab, :theme)
else
enter_theme_edit_mode(socket)
end
:page ->
# Enter page edit mode if we have a page and aren't already editing
socket = assign(socket, :editor_active_tab, :page)
if socket.assigns[:page] && !socket.assigns.editing do
enter_edit_mode(socket)
else
socket
end
:settings ->
# Ensure theme state is loaded for settings that need it
socket =
if socket.assigns.theme_editing do
socket
else
load_theme_state(socket)
end
assign(socket, :editor_active_tab, :settings)
end
{:halt, socket}
else
{:cont, socket}
end
end
# Toggle theme editing mode
defp handle_editor_event("editor_toggle_theme", _params, socket) do
if socket.assigns.is_admin do
if socket.assigns.theme_editing do
{:halt, exit_theme_edit_mode(socket)}
else
{:halt, enter_theme_edit_mode(socket)}
end
else
{:cont, socket}
end
end
defp handle_editor_event("editor_" <> action, params, socket) do defp handle_editor_event("editor_" <> action, params, socket) do
if socket.assigns.editing do if socket.assigns.editing do
handle_editor_action(action, params, socket) handle_editor_action(action, params, socket)
@ -100,6 +173,15 @@ defmodule BerrypodWeb.PageEditorHook do
end end
end end
# Theme editing events
defp handle_editor_event("theme_" <> action, params, socket) do
if socket.assigns.is_admin && socket.assigns.theme_editing do
handle_theme_action(action, params, socket)
else
{:cont, socket}
end
end
defp handle_editor_event(_event, _params, socket), do: {:cont, socket} defp handle_editor_event(_event, _params, socket), do: {:cont, socket}
# ── Block manipulation actions ─────────────────────────────────── # ── Block manipulation actions ───────────────────────────────────
@ -417,6 +499,200 @@ defmodule BerrypodWeb.PageEditorHook do
# Catch-all for unknown editor actions # Catch-all for unknown editor actions
defp handle_editor_action(_action, _params, socket), do: {:halt, socket} defp handle_editor_action(_action, _params, socket), do: {:halt, socket}
# ── Theme editing actions ───────────────────────────────────────────
# Settings stored outside the theme JSON (site_name, site_description)
@standalone_settings ~w(site_name site_description)
defp handle_theme_action("apply_preset", %{"preset" => preset_name}, socket) do
preset_atom = String.to_existing_atom(preset_name)
case Settings.apply_preset(preset_atom) do
{:ok, theme_settings} ->
generated_css =
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
header_image = socket.assigns.theme_editor_header_image
contrast_warning = compute_header_contrast(theme_settings, header_image)
socket =
socket
# Update editor state
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_editor_active_preset, preset_atom)
|> assign(:theme_editor_contrast_warning, contrast_warning)
# Update shop state so layout reflects changes live
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
{:halt, socket}
{:error, _} ->
{:halt, socket}
end
end
defp handle_theme_action(
"update_setting",
%{"field" => field, "setting_value" => value},
socket
)
when field in @standalone_settings do
Settings.put_setting(field, value, "string")
# Also update the main assigns so ThemeHook sees the change
{:halt, assign(socket, String.to_existing_atom(field), value)}
end
defp handle_theme_action(
"update_setting",
%{"field" => field, "setting_value" => value},
socket
) do
field_atom = String.to_existing_atom(field)
update_theme_setting(socket, %{field_atom => value}, field)
end
defp handle_theme_action("update_setting", %{"field" => field} = params, socket)
when field in @standalone_settings do
value = params[field]
if value do
Settings.put_setting(field, value, "string")
{:halt, assign(socket, String.to_existing_atom(field), value)}
else
{:halt, socket}
end
end
defp handle_theme_action("update_setting", %{"field" => field} = params, socket) do
value = params[field] || params["#{field}_text"] || params["value"]
if value do
field_atom = String.to_existing_atom(field)
update_theme_setting(socket, %{field_atom => value}, field)
else
{:halt, socket}
end
end
defp handle_theme_action("update_color", %{"field" => field, "value" => value}, socket) do
field_atom = String.to_existing_atom(field)
update_theme_setting(socket, %{field_atom => value}, field)
end
defp handle_theme_action("toggle_setting", %{"field" => field}, socket) do
field_atom = String.to_existing_atom(field)
current_value = Map.get(socket.assigns.theme_editor_settings, field_atom)
new_value = !current_value
# Prevent turning off show_site_name when there's no logo
if field_atom == :show_site_name && new_value == false && !has_valid_logo?(socket) do
{:halt, socket}
else
update_theme_setting(socket, %{field_atom => new_value}, field)
end
end
defp handle_theme_action("toggle_customise", _params, socket) do
{:halt,
assign(socket, :theme_editor_customise_open, !socket.assigns.theme_editor_customise_open)}
end
defp handle_theme_action("remove_logo", _params, socket) do
if logo = socket.assigns.theme_editor_logo_image do
Media.delete_image(logo)
end
{:ok, theme_settings} =
Settings.update_theme_settings(%{logo_image_id: nil, show_site_name: true})
generated_css = CSSGenerator.generate(theme_settings)
socket =
socket
|> assign(:theme_editor_logo_image, nil)
|> assign(:logo_image, nil)
|> assign(:theme_editor_settings, theme_settings)
|> assign(:generated_css, generated_css)
{:halt, socket}
end
defp handle_theme_action("remove_header", _params, socket) do
if header = socket.assigns.theme_editor_header_image do
Media.delete_image(header)
end
Settings.update_theme_settings(%{header_image_id: nil})
socket =
socket
|> assign(:theme_editor_header_image, nil)
|> assign(:header_image, nil)
|> assign(:theme_editor_contrast_warning, :ok)
{:halt, socket}
end
defp handle_theme_action("remove_icon", _params, socket) do
if icon = socket.assigns.theme_editor_icon_image do
Media.delete_image(icon)
end
Settings.update_theme_settings(%{icon_image_id: nil})
socket =
socket
|> assign(:theme_editor_icon_image, nil)
|> assign(:icon_image, nil)
{:halt, socket}
end
# Catch-all for unknown theme actions
defp handle_theme_action(_action, _params, socket), do: {:halt, socket}
# Helper to update a theme setting and regenerate CSS
defp update_theme_setting(socket, attrs, field) do
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css =
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
active_preset = Presets.detect_preset(theme_settings)
socket =
socket
# Update editor state
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_editor_active_preset, active_preset)
|> maybe_recompute_contrast(field)
# Update shop state so layout reflects changes live
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
{:halt, socket}
{:error, _} ->
{:halt, socket}
end
end
defp maybe_recompute_contrast(socket, field)
when field in ["mood", "header_background_enabled"] do
header_image = socket.assigns.theme_editor_header_image
theme_settings = socket.assigns.theme_editor_settings
contrast_warning = compute_header_contrast(theme_settings, header_image)
assign(socket, :theme_editor_contrast_warning, contrast_warning)
end
defp maybe_recompute_contrast(socket, _field), do: socket
defp has_valid_logo?(socket) do
socket.assigns.theme_editor_settings.show_logo &&
socket.assigns.theme_editor_logo_image != nil
end
# ── Private helpers ────────────────────────────────────────────── # ── Private helpers ──────────────────────────────────────────────
defp enter_edit_mode(socket) do defp enter_edit_mode(socket) do
@ -458,7 +734,6 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editor_allowed_blocks, nil) |> assign(:editor_allowed_blocks, nil)
|> assign(:editor_live_region_message, nil) |> assign(:editor_live_region_message, nil)
|> assign(:editor_sidebar_open, true) |> assign(:editor_sidebar_open, true)
|> assign(:editor_sheet_state, :collapsed)
|> assign(:editor_image_picker_block_id, nil) |> assign(:editor_image_picker_block_id, nil)
|> assign(:editor_image_picker_field_key, nil) |> assign(:editor_image_picker_field_key, nil)
|> assign(:editor_image_picker_images, []) |> assign(:editor_image_picker_images, [])
@ -491,4 +766,61 @@ defmodule BerrypodWeb.PageEditorHook do
extra = Pages.load_block_data(blocks, socket.assigns) extra = Pages.load_block_data(blocks, socket.assigns)
Enum.reduce(extra, socket, fn {key, value}, acc -> assign(acc, key, value) end) Enum.reduce(extra, socket, fn {key, value}, acc -> assign(acc, key, value) end)
end end
# ── Theme editing helpers ───────────────────────────────────────────
# Load theme state without changing tabs (for settings tab that needs theme data)
defp load_theme_state(socket) do
theme_settings = Settings.get_theme_settings()
generated_css =
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
active_preset = Presets.detect_preset(theme_settings)
logo_image = Media.get_logo()
header_image = Media.get_header()
icon_image = Media.get_icon()
contrast_warning = compute_header_contrast(theme_settings, header_image)
socket
|> assign(:theme_editing, true)
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_editor_active_preset, active_preset)
|> assign(:theme_editor_logo_image, logo_image)
|> assign(:theme_editor_header_image, header_image)
|> assign(:theme_editor_icon_image, icon_image)
|> assign(:theme_editor_contrast_warning, contrast_warning)
|> assign(:theme_editor_customise_open, false)
# Update both editor and shop state
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:editor_sheet_state, :open)
end
defp enter_theme_edit_mode(socket) do
socket
|> load_theme_state()
|> assign(:editor_active_tab, :theme)
end
defp exit_theme_edit_mode(socket) do
socket
|> assign(:theme_editing, false)
|> assign(:theme_editor_settings, nil)
|> assign(:theme_editor_active_preset, nil)
|> assign(:theme_editor_contrast_warning, :ok)
|> assign(:theme_editor_customise_open, false)
end
defp compute_header_contrast(theme_settings, header_image) do
if theme_settings.header_background_enabled && header_image do
text_color = Contrast.text_color_for_mood(theme_settings.mood)
colors = Contrast.parse_dominant_colors(header_image.dominant_colors)
Contrast.analyze_header_contrast(colors, text_color)
else
:ok
end
end
end end

View File

@ -81,14 +81,18 @@ defmodule BerrypodWeb.PageRenderer do
</main> </main>
</.shop_layout> </.shop_layout>
<%!-- Editor sheet for page editing --%> <%!-- Editor sheet for page/theme/settings editing --%>
<.editor_sheet <.editor_sheet
editing={@editing} editing={@editing}
theme_editing={Map.get(assigns, :theme_editing, false)}
editor_dirty={@editor_dirty} editor_dirty={@editor_dirty}
editor_sheet_state={assigns[:editor_sheet_state] || :collapsed} editor_sheet_state={assigns[:editor_sheet_state] || :collapsed}
editor_save_status={@editor_save_status} editor_save_status={@editor_save_status}
editor_active_tab={Map.get(assigns, :editor_active_tab, :page)}
has_editable_page={@page != nil}
> >
<.editor_sheet_content <.editor_panel_content
editor_active_tab={Map.get(assigns, :editor_active_tab, :page)}
page={@page} page={@page}
editing_blocks={@editing_blocks} editing_blocks={@editing_blocks}
editor_history={@editor_history} editor_history={@editor_history}
@ -103,13 +107,247 @@ defmodule BerrypodWeb.PageRenderer do
editor_image_picker_images={@editor_image_picker_images} editor_image_picker_images={@editor_image_picker_images}
editor_image_picker_search={@editor_image_picker_search} editor_image_picker_search={@editor_image_picker_search}
editor_at_defaults={Map.get(assigns, :editor_at_defaults, true)} editor_at_defaults={Map.get(assigns, :editor_at_defaults, true)}
theme_editor_settings={Map.get(assigns, :theme_editor_settings)}
theme_editor_active_preset={Map.get(assigns, :theme_editor_active_preset)}
theme_editor_presets={Map.get(assigns, :theme_editor_presets, [])}
theme_editor_customise_open={Map.get(assigns, :theme_editor_customise_open, false)}
site_name={Map.get(assigns, :site_name, "")}
/> />
</.editor_sheet> </.editor_sheet>
""" """
end end
# Editor panel content dispatcher - shows content based on active tab
attr :editor_active_tab, :atom, default: :page
attr :page, :map, default: nil
attr :editing_blocks, :list, default: nil
attr :editor_history, :list, default: []
attr :editor_future, :list, default: []
attr :editor_dirty, :boolean, default: false
attr :editor_live_region_message, :string, default: nil
attr :editor_expanded, :any, default: nil
attr :editor_show_picker, :boolean, default: false
attr :editor_picker_filter, :string, default: ""
attr :editor_allowed_blocks, :list, default: nil
attr :editor_image_picker_block_id, :string, default: nil
attr :editor_image_picker_images, :list, default: []
attr :editor_image_picker_search, :string, default: ""
attr :editor_at_defaults, :boolean, default: true
attr :theme_editor_settings, :map, default: nil
attr :theme_editor_active_preset, :atom, default: nil
attr :theme_editor_presets, :list, default: []
attr :theme_editor_customise_open, :boolean, default: false
attr :site_name, :string, default: ""
defp editor_panel_content(%{editor_active_tab: :page} = assigns) do
~H"""
<.editor_sheet_content
page={@page}
editing_blocks={@editing_blocks}
editor_history={@editor_history}
editor_future={@editor_future}
editor_dirty={@editor_dirty}
editor_live_region_message={@editor_live_region_message}
editor_expanded={@editor_expanded}
editor_show_picker={@editor_show_picker}
editor_picker_filter={@editor_picker_filter}
editor_allowed_blocks={@editor_allowed_blocks}
editor_image_picker_block_id={@editor_image_picker_block_id}
editor_image_picker_images={@editor_image_picker_images}
editor_image_picker_search={@editor_image_picker_search}
editor_at_defaults={@editor_at_defaults}
/>
"""
end
defp editor_panel_content(%{editor_active_tab: :theme} = assigns) do
~H"""
<.theme_editor_content
theme_editor_settings={@theme_editor_settings}
theme_editor_active_preset={@theme_editor_active_preset}
theme_editor_presets={@theme_editor_presets}
theme_editor_customise_open={@theme_editor_customise_open}
site_name={@site_name}
/>
"""
end
defp editor_panel_content(%{editor_active_tab: :settings} = assigns) do
~H"""
<.settings_editor_content page={@page} site_name={@site_name} />
"""
end
# Theme editor content - shows theme controls
attr :theme_editor_settings, :map, default: nil
attr :theme_editor_active_preset, :atom, default: nil
attr :theme_editor_presets, :list, default: []
attr :theme_editor_customise_open, :boolean, default: false
attr :site_name, :string, default: ""
defp theme_editor_content(assigns) do
~H"""
<div class="editor-theme-content">
<%= if @theme_editor_settings do %>
<%!-- Shop name --%>
<div class="theme-section">
<label class="theme-section-label">Shop name</label>
<form phx-change="theme_update_setting" phx-value-field="site_name">
<input
type="text"
name="site_name"
value={@site_name}
placeholder="Your shop name"
class="admin-input"
/>
</form>
</div>
<%!-- Presets --%>
<div class="theme-section">
<label class="theme-section-label">Preset</label>
<div class="theme-presets">
<%= for {preset_name, description} <- @theme_editor_presets do %>
<button
type="button"
phx-click="theme_apply_preset"
phx-value-preset={preset_name}
class={[
"theme-preset",
@theme_editor_active_preset == preset_name && "theme-preset-active"
]}
>
<div class="theme-preset-name">{preset_name}</div>
<div class="theme-preset-desc">{description}</div>
</button>
<% end %>
</div>
</div>
<%!-- Mood --%>
<div class="theme-section">
<label class="theme-section-label">Colour mood</label>
<div class="theme-chips">
<%= for mood <- ["warm", "neutral", "cool", "dark"] do %>
<button
type="button"
phx-click="theme_update_setting"
phx-value-field="mood"
phx-value-setting_value={mood}
class={["theme-chip", @theme_editor_settings.mood == mood && "theme-chip-active"]}
>
{mood}
</button>
<% end %>
</div>
</div>
<%!-- Typography --%>
<div class="theme-section">
<label class="theme-section-label">Font style</label>
<div class="theme-chips">
<%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal"] do %>
<button
type="button"
phx-click="theme_update_setting"
phx-value-field="typography"
phx-value-setting_value={typo}
class={[
"theme-chip",
@theme_editor_settings.typography == typo && "theme-chip-active"
]}
>
{typo}
</button>
<% end %>
</div>
</div>
<%!-- Shape --%>
<div class="theme-section">
<label class="theme-section-label">Corner style</label>
<div class="theme-chips">
<%= for shape <- ["sharp", "soft", "round", "pill"] do %>
<button
type="button"
phx-click="theme_update_setting"
phx-value-field="shape"
phx-value-setting_value={shape}
class={["theme-chip", @theme_editor_settings.shape == shape && "theme-chip-active"]}
>
{shape}
</button>
<% end %>
</div>
</div>
<%!-- More options link --%>
<details
class="theme-customise"
id="theme-customise-section"
open={@theme_editor_customise_open}
>
<summary class="theme-customise-summary" phx-click="theme_toggle_customise">
<span class="theme-customise-label">More options</span>
<svg
class="theme-customise-chevron"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</summary>
<div class="theme-customise-body">
<p class="admin-text-secondary">
For full theme customisation including branding, colours, and layout, <a
href="/admin/theme"
class="admin-link"
>visit the theme editor</a>.
</p>
</div>
</details>
<% else %>
<p class="admin-text-secondary">Loading theme settings...</p>
<% end %>
</div>
"""
end
# Settings editor content - shows page/shop settings
attr :page, :map, default: nil
attr :site_name, :string, default: ""
defp settings_editor_content(assigns) do
~H"""
<div class="editor-settings-content">
<%= if @page do %>
<div class="theme-section">
<label class="theme-section-label">Page</label>
<p class="admin-text-secondary">{@page.title}</p>
</div>
<div class="theme-section">
<p class="admin-text-secondary">
Page settings like SEO, visibility, and slug editing coming soon.
For now, <a href="/admin/pages" class="admin-link">manage pages in admin</a>.
</p>
</div>
<% else %>
<div class="theme-section">
<p class="admin-text-secondary">
This page doesn't have editable settings.
<a href="/admin/settings" class="admin-link">Shop settings</a>
can be changed in admin.
</p>
</div>
<% end %>
</div>
"""
end
# Editor sheet content - the block list and editing controls # Editor sheet content - the block list and editing controls
attr :page, :map, required: true attr :page, :map, default: nil
attr :editing_blocks, :list, default: nil attr :editing_blocks, :list, default: nil
attr :editor_history, :list, default: [] attr :editor_history, :list, default: []
attr :editor_future, :list, default: [] attr :editor_future, :list, default: []

View File

@ -43,14 +43,14 @@ defmodule BerrypodWeb.PageEditorHookTest do
{:ok, view, _html} = live(conn, "/") {:ok, view, _html} = live(conn, "/")
assert has_element?(view, ".editor-panel") assert has_element?(view, ".editor-panel")
assert has_element?(view, "button", "Edit page") assert has_element?(view, "button.editor-fab")
end end
test "non-admin does not see editor sheet", %{conn: conn} do test "non-admin does not see editor sheet", %{conn: conn} do
{:ok, view, _html} = live(conn, "/") {:ok, view, _html} = live(conn, "/")
refute has_element?(view, ".editor-panel") refute has_element?(view, ".editor-panel")
refute has_element?(view, "button", "Edit page") refute has_element?(view, "button.editor-fab")
end end
end end