implement unified on-site editor phases 1-2
All checks were successful
deploy / deploy (push) Successful in 1m10s
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:
parent
74ab6411f7
commit
168b6ce76f
@ -87,8 +87,8 @@ Extend the existing page editor (PageEditorHook + editor_sheet) to include theme
|
||||
|
||||
| Phase | Description | Est | Status |
|
||||
|---|---|---|---|
|
||||
| 1 | Add theme editing state to PageEditorHook | 2h | planned |
|
||||
| 2 | Add 3-tab UI to editor panel (Page/Theme/Settings) | 2h | planned |
|
||||
| 1 | Add theme editing state to PageEditorHook | 2h | done |
|
||||
| 2 | Add 3-tab UI to editor panel (Page/Theme/Settings) | 2h | done |
|
||||
| 3 | Extract theme editor into reusable component | 3h | planned |
|
||||
| 3b | Create settings editor component | 2h | planned |
|
||||
| 4 | Image upload handling in hook context | 2h | planned |
|
||||
|
||||
@ -2525,6 +2525,15 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Editor overlay ── */
|
||||
.editor-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 999;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Editor panel ── */
|
||||
.editor-panel {
|
||||
position: fixed;
|
||||
@ -2533,6 +2542,10 @@
|
||||
flex-direction: column;
|
||||
background: var(--t-surface-base);
|
||||
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) */
|
||||
@ -2540,6 +2553,34 @@
|
||||
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 ── */
|
||||
@media (max-width: 767px) {
|
||||
.editor-panel[data-state="open"],
|
||||
@ -2547,13 +2588,26 @@
|
||||
left: 0;
|
||||
right: 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: 1px solid var(--t-border-default);
|
||||
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;
|
||||
}
|
||||
|
||||
@ -2640,10 +2694,30 @@
|
||||
.editor-panel-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
gap: 0.5rem;
|
||||
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 ── */
|
||||
.editor-panel-dirty {
|
||||
display: flex;
|
||||
@ -2661,12 +2735,50 @@
|
||||
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 ── */
|
||||
.editor-panel-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.75rem;
|
||||
/* Ensure content is clipped to panel bounds during drag */
|
||||
contain: paint;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 1rem;
|
||||
|
||||
129
assets/js/app.js
129
assets/js/app.js
@ -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 = {
|
||||
mounted() {
|
||||
this._onDocMousedown = (e) => {
|
||||
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)
|
||||
|
||||
// Close on Escape key
|
||||
this._onKeydown = (e) => {
|
||||
if (e.key === "Escape" && this._getState() === "open") {
|
||||
e.preventDefault()
|
||||
@ -698,11 +690,29 @@ const EditorSheet = {
|
||||
}
|
||||
}
|
||||
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() {
|
||||
document.removeEventListener("mousedown", this._onDocMousedown)
|
||||
document.removeEventListener("keydown", this._onKeydown)
|
||||
this._cleanupDragHandle()
|
||||
},
|
||||
|
||||
_getState() {
|
||||
@ -732,6 +742,103 @@ const EditorSheet = {
|
||||
if (region) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -326,10 +326,6 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
||||
end
|
||||
end
|
||||
|
||||
defp has_valid_logo?(socket) do
|
||||
socket.assigns.theme_settings.show_logo && socket.assigns.logo_image != nil
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save_theme", _params, socket) do
|
||||
socket = put_flash(socket, :info, "Theme saved successfully")
|
||||
@ -452,6 +448,10 @@ defmodule BerrypodWeb.Admin.Theme.Index do
|
||||
{:noreply, socket}
|
||||
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_many_files), do: "Too many files"
|
||||
def error_to_string(:not_accepted), do: "File type not accepted"
|
||||
|
||||
@ -330,8 +330,7 @@
|
||||
<div class="theme-subfield">
|
||||
<form phx-change="update_setting" phx-value-field="favicon_short_name">
|
||||
<label class="theme-slider-label theme-block-label">
|
||||
Short name
|
||||
<span class="admin-text-tertiary">— appears under home screen icon</span>
|
||||
Short name <span class="admin-text-tertiary">— appears under home screen icon</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@ -441,7 +440,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Header Image Controls -->
|
||||
<div class="admin-stack admin-stack-md theme-subfield">
|
||||
<form phx-change="update_setting" phx-value-field="header_zoom">
|
||||
@ -521,7 +520,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Presets Section -->
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label">Start with a preset</label>
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
defmodule BerrypodWeb.PageEditorHook do
|
||||
@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
|
||||
page with `?edit=true` in the URL, this hook activates editing mode:
|
||||
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`.
|
||||
page, this hook enables editing capabilities:
|
||||
|
||||
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`.
|
||||
|
||||
## Actions
|
||||
@ -18,12 +20,15 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
import Phoenix.LiveView, only: [attach_hook: 4, push_navigate: 2]
|
||||
|
||||
alias Berrypod.{Media, Settings}
|
||||
alias Berrypod.Pages
|
||||
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
|
||||
alias Berrypod.Theme.{Contrast, CSSGenerator, Presets}
|
||||
|
||||
def on_mount(:mount_page_editor, _params, _session, socket) do
|
||||
socket =
|
||||
socket
|
||||
# Page editing state
|
||||
|> assign(:editing, false)
|
||||
|> assign(:editing_blocks, nil)
|
||||
|> assign(:editor_dirty, false)
|
||||
@ -43,6 +48,18 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_image_picker_images, [])
|
||||
|> assign(:editor_image_picker_search, "")
|
||||
|> 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_events, :handle_event, &handle_editor_event/3)
|
||||
|> 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)
|
||||
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
|
||||
{:halt, assign(socket, :editor_sheet_state, state)}
|
||||
else
|
||||
@ -92,6 +109,62 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
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
|
||||
if socket.assigns.editing do
|
||||
handle_editor_action(action, params, socket)
|
||||
@ -100,6 +173,15 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
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}
|
||||
|
||||
# ── Block manipulation actions ───────────────────────────────────
|
||||
@ -417,6 +499,200 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
# Catch-all for unknown editor actions
|
||||
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 ──────────────────────────────────────────────
|
||||
|
||||
defp enter_edit_mode(socket) do
|
||||
@ -458,7 +734,6 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_allowed_blocks, nil)
|
||||
|> assign(:editor_live_region_message, nil)
|
||||
|> assign(:editor_sidebar_open, true)
|
||||
|> assign(:editor_sheet_state, :collapsed)
|
||||
|> assign(:editor_image_picker_block_id, nil)
|
||||
|> assign(:editor_image_picker_field_key, nil)
|
||||
|> assign(:editor_image_picker_images, [])
|
||||
@ -491,4 +766,61 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
extra = Pages.load_block_data(blocks, socket.assigns)
|
||||
Enum.reduce(extra, socket, fn {key, value}, acc -> assign(acc, key, value) 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
|
||||
|
||||
@ -81,14 +81,18 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
</main>
|
||||
</.shop_layout>
|
||||
|
||||
<%!-- Editor sheet for page editing --%>
|
||||
<%!-- Editor sheet for page/theme/settings editing --%>
|
||||
<.editor_sheet
|
||||
editing={@editing}
|
||||
theme_editing={Map.get(assigns, :theme_editing, false)}
|
||||
editor_dirty={@editor_dirty}
|
||||
editor_sheet_state={assigns[:editor_sheet_state] || :collapsed}
|
||||
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}
|
||||
editing_blocks={@editing_blocks}
|
||||
editor_history={@editor_history}
|
||||
@ -103,13 +107,247 @@ defmodule BerrypodWeb.PageRenderer do
|
||||
editor_image_picker_images={@editor_image_picker_images}
|
||||
editor_image_picker_search={@editor_image_picker_search}
|
||||
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>
|
||||
"""
|
||||
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
|
||||
attr :page, :map, required: true
|
||||
attr :page, :map, default: nil
|
||||
attr :editing_blocks, :list, default: nil
|
||||
attr :editor_history, :list, default: []
|
||||
attr :editor_future, :list, default: []
|
||||
|
||||
@ -43,14 +43,14 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
||||
{:ok, view, _html} = live(conn, "/")
|
||||
|
||||
assert has_element?(view, ".editor-panel")
|
||||
assert has_element?(view, "button", "Edit page")
|
||||
assert has_element?(view, "button.editor-fab")
|
||||
end
|
||||
|
||||
test "non-admin does not see editor sheet", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/")
|
||||
|
||||
refute has_element?(view, ".editor-panel")
|
||||
refute has_element?(view, "button", "Edit page")
|
||||
refute has_element?(view, "button.editor-fab")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user