From 168b6ce76f5693a0a233b541bd60e7bb6020ccee Mon Sep 17 00:00:00 2001 From: jamey Date: Mon, 9 Mar 2026 09:01:21 +0000 Subject: [PATCH] implement unified on-site editor phases 1-2 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 --- PROGRESS.md | 4 +- assets/css/admin/components.css | 118 +++++- assets/js/app.js | 129 ++++++- docs/plans/unified-editing-mode.md | 2 +- .../components/shop_components/layout.ex | 145 +++++++- lib/berrypod_web/live/admin/theme/index.ex | 8 +- .../live/admin/theme/index.html.heex | 7 +- lib/berrypod_web/page_editor_hook.ex | 346 +++++++++++++++++- lib/berrypod_web/page_renderer.ex | 244 +++++++++++- test/berrypod_web/page_editor_hook_test.exs | 4 +- 10 files changed, 954 insertions(+), 53 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 65925bc..474f787 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 | diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 9d37704..38078bf 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -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; diff --git a/assets/js/app.js b/assets/js/app.js index 9c58b5d..1ae8f07 100644 --- a/assets/js/app.js +++ b/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") } } diff --git a/docs/plans/unified-editing-mode.md b/docs/plans/unified-editing-mode.md index 08fe112..1ced888 100644 --- a/docs/plans/unified-editing-mode.md +++ b/docs/plans/unified-editing-mode.md @@ -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. diff --git a/lib/berrypod_web/components/shop_components/layout.ex b/lib/berrypod_web/components/shop_components/layout.ex index ac72b43..9dcd9ae 100644 --- a/lib/berrypod_web/components/shop_components/layout.ex +++ b/lib/berrypod_web/components/shop_components/layout.ex @@ -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
+ <%!-- Live-updatable theme CSS (overrides static version in head) --%> + <%= if @generated_css do %> + {Phoenix.HTML.raw("")} + <% 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 --%> + <%!-- Overlay to catch taps outside the panel --%> +