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 |
|
| 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 |
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
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 = {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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: []
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user