replace admin rail with unified bottom sheet editor
All checks were successful
deploy / deploy (push) Successful in 1m30s

- add editor sheet component anchored bottom (mobile) / right (desktop)
- admin cog moves to header, always visible for admins
- remove Done button from editor header, keep only Save
- add editor_at_defaults tracking to disable Reset when at defaults
- sheet collapses on click outside or Escape, stays in edit mode
- dirty indicator + beforeunload warning for unsaved changes
- keyboard shortcuts: Ctrl+Z undo, Ctrl+Shift+Z redo
- WCAG compliant: aria-expanded, live region, focus management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-03-07 09:30:07 +00:00
parent dbcecc7878
commit f4f036b84b
12 changed files with 1232 additions and 474 deletions

View File

@ -631,33 +631,23 @@
/* ── Feedback ── */
.admin-toast {
position: fixed;
top: 1rem;
inset-inline-end: 1rem;
z-index: 100;
.admin-banner {
/* document flow — pushes content down, no overlay */
}
/* Hide connection error banners during intentional navigation */
.navigating-away #flash-group { display: none; }
.admin-alert {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
line-height: 1.5;
width: 20rem;
max-width: 20rem;
overflow-wrap: break-word;
}
@media (min-width: 640px) {
.admin-alert {
width: 24rem;
max-width: 24rem;
}
}
.admin-alert-title {
font-weight: 600;
}
@ -669,13 +659,13 @@
.admin-alert-info {
background-color: color-mix(in oklch, var(--t-status-info) 12%, var(--t-surface-base));
color: var(--t-status-info);
border: 1px solid color-mix(in oklch, var(--t-status-info) 25%, var(--t-surface-base));
border-bottom: 1px solid color-mix(in oklch, var(--t-status-info) 25%, var(--t-surface-base));
}
.admin-alert-error {
background-color: color-mix(in oklch, var(--t-status-error) 12%, var(--t-surface-base));
color: var(--t-status-error);
border: 1px solid color-mix(in oklch, var(--t-status-error) 25%, var(--t-surface-base));
border-bottom: 1px solid color-mix(in oklch, var(--t-status-error) 25%, var(--t-surface-base));
}
.admin-alert-close {
@ -686,6 +676,27 @@
&:hover { opacity: 0.7; }
}
.admin-inline-feedback {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
line-height: 1.5;
}
.admin-inline-feedback-saving {
color: var(--admin-text-soft);
}
.admin-inline-feedback-saved {
color: var(--t-status-success, oklch(0.55 0.15 145));
}
.admin-inline-feedback-error {
color: var(--t-status-error);
font-weight: 600;
}
.admin-banner-warning {
display: flex;
align-items: center;
@ -2020,8 +2031,12 @@
.block-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-top: 0;
@media (min-width: 768px) {
gap: 0.5rem;
margin-top: 1.5rem;
}
}
.block-list-empty {
@ -2034,35 +2049,50 @@
}
.block-card {
padding: 0.5rem 0.75rem;
padding: 0.375rem 0.5rem;
border: 1px solid var(--t-border-default);
border-radius: 0.5rem;
border-radius: 0.375rem;
background: var(--t-surface-base);
transition: box-shadow 150ms;
&:focus-within {
box-shadow: 0 0 0 2px color-mix(in oklch, var(--t-text-primary) 20%, transparent);
}
@media (min-width: 768px) {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
}
}
.block-card-position {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--admin-text-faint);
flex-shrink: 0;
@media (min-width: 768px) {
width: 1.5rem;
height: 1.5rem;
font-size: 0.75rem;
}
}
.block-card-icon {
display: flex;
display: none;
align-items: center;
color: var(--admin-text-muted);
flex-shrink: 0;
@media (min-width: 768px) {
display: flex;
}
}
.block-card-info {
@ -2072,20 +2102,28 @@
.block-card-name {
display: block;
font-size: 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@media (min-width: 768px) {
font-size: 0.875rem;
}
}
.block-card-preview {
display: block;
display: none;
font-size: 0.75rem;
color: var(--admin-text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@media (min-width: 768px) {
display: block;
}
}
.block-card-controls {
@ -2233,6 +2271,11 @@
gap: 0.25rem 0.5rem;
flex-wrap: wrap;
width: 100%;
/* Desktop: single row with truncating text */
@media (min-width: 768px) {
flex-wrap: nowrap;
}
}
.block-card-expanded {
@ -2334,115 +2377,275 @@
border-style: dashed;
}
/* ── Live editor layout (sidebar on shop pages) ── */
/*
Editor sheet (unified bottom/right sheet for page editing)
*/
.page-editor-live {
display: flex;
min-height: 100vh;
}
.page-editor-sidebar {
.editor-sheet {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 360px;
z-index: 1000;
background: var(--t-surface-base);
border-right: 1px solid var(--t-border-default);
overflow-y: auto;
z-index: 40;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
padding: 1rem;
transition: transform 0.25s ease;
box-shadow: var(--t-shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15));
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
display: flex;
flex-direction: column;
}
@media (prefers-reduced-motion: reduce) {
.editor-sheet {
transition: none;
}
}
/* ── Mobile: bottom-anchored ── */
@media (max-width: 767px) {
.editor-sheet {
bottom: 0;
left: 0;
right: 0;
height: 85dvh;
border-radius: var(--t-radius-lg, 12px) var(--t-radius-lg, 12px) 0 0;
transform: translateY(calc(100% - 48px));
}
.editor-sheet[data-state="open"] {
transform: translateY(0);
}
}
/* ── Desktop: right-anchored ── */
@media (min-width: 768px) {
.editor-sheet {
top: 0;
right: 0;
bottom: 0;
border-radius: var(--t-radius-lg, 12px) 0 0 var(--t-radius-lg, 12px);
width: 420px;
max-width: 90vw;
transform: translateX(calc(100% - 48px));
flex-direction: column;
}
.editor-sheet[data-state="open"] {
transform: translateX(0);
}
}
/* Hidden sidebar — slides off-screen */
[data-sidebar-open="false"] .page-editor-sidebar {
transform: translateX(-100%);
box-shadow: none;
/* ── Edit button in collapsed state ── */
.editor-sheet-edit-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: var(--t-accent);
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: filter 0.15s;
}
.page-editor-sidebar-header {
.editor-sheet-edit-btn:hover {
filter: brightness(1.1);
}
.editor-sheet-edit-btn svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* Desktop collapsed: icon-only button with tooltip */
@media (min-width: 768px) {
.editor-sheet[data-state="collapsed"] .editor-sheet-edit-btn {
padding: 0.5rem;
position: relative;
}
.editor-sheet[data-state="collapsed"] .editor-sheet-edit-btn span {
/* Visually hidden but accessible to screen readers */
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Tooltip on hover - dark tooltip for visibility on any theme */
.editor-sheet[data-state="collapsed"] .editor-sheet-edit-btn::after {
content: "Edit page";
position: absolute;
right: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
padding: 0.375rem 0.5rem;
background: #1a1a1a;
color: #fff;
font-size: 0.75rem;
font-weight: 500;
border-radius: 4px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 10;
}
.editor-sheet[data-state="collapsed"] .editor-sheet-edit-btn:hover::after,
.editor-sheet[data-state="collapsed"] .editor-sheet-edit-btn:focus::after {
opacity: 1;
}
}
/* ── Dirty indicator ── */
.editor-sheet-dirty {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--t-status-warning, oklch(0.75 0.18 85));
font-size: 0.75rem;
font-weight: 500;
}
.editor-sheet-dirty-dot {
width: 8px;
height: 8px;
border-radius: 9999px;
background: currentColor;
}
/* Desktop collapsed: hide "Unsaved" text, show only dot */
@media (min-width: 768px) {
.editor-sheet[data-state="collapsed"] .editor-sheet-dirty span:not(.editor-sheet-dirty-dot) {
/* Visually hidden but accessible to screen readers */
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.editor-sheet[data-state="collapsed"] .editor-sheet-dirty {
position: relative;
}
}
/* ── Sheet header (when expanded) ── */
.editor-sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--t-border-default);
flex-shrink: 0;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.page-editor-sidebar-title {
font-size: 1rem;
font-weight: 600;
flex: 1;
.editor-sheet-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.page-editor-sidebar-actions {
.editor-sheet-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--t-text-primary);
}
.editor-sheet-header-actions {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.page-editor-sidebar-dirty {
margin-bottom: 0.5rem;
/* ── Sheet content ── */
.editor-sheet-content {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.75rem;
@media (min-width: 768px) {
padding: 1rem;
}
}
/* Picker inside the editor sidebar — grid scrolls within a capped height */
.page-editor-sidebar .block-picker-overlay {
/* ── Hide content when collapsed ── */
.editor-sheet[data-state="collapsed"] .editor-sheet-content {
display: none;
}
/* Collapsed header doesn't need bottom border */
.editor-sheet[data-state="collapsed"] .editor-sheet-header {
border-bottom: none;
}
/* ── Page header inside sheet ── */
.editor-sheet-page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
@media (min-width: 768px) {
margin-bottom: 0.75rem;
}
}
.editor-sheet-page-title {
font-size: 0.9375rem;
font-weight: 600;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@media (min-width: 768px) {
font-size: 1rem;
}
}
.editor-sheet-undo-redo {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
/* Picker inside the editor sheet — grid scrolls within a capped height */
.editor-sheet .block-picker-overlay {
position: static;
background: none;
}
.page-editor-sidebar .block-picker {
.editor-sheet .block-picker {
border-radius: 0;
max-height: none;
padding: 0;
}
.page-editor-sidebar .block-picker-grid {
.editor-sheet .block-picker-grid {
max-height: 45dvh;
overflow-y: auto;
}
.page-editor-content {
flex: 1;
margin-left: 360px;
min-width: 0;
transition: margin-left 0.25s ease;
}
/* Content goes full-width when sidebar is hidden */
[data-sidebar-open="false"] .page-editor-content {
margin-left: 0;
}
/* Clickable backdrop to dismiss the sidebar */
.page-editor-backdrop {
position: fixed;
inset: 0;
z-index: 39;
background: rgba(0, 0, 0, 0.15);
cursor: pointer;
}
/* Mobile: sidebar overlays content, no margin push */
@media (max-width: 63.99em) {
.page-editor-sidebar {
width: 85%;
max-width: 360px;
padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px) + 1rem);
}
.page-editor-content {
margin-left: 0;
}
}
/*
Image field (block editor)
*/

View File

@ -1089,6 +1089,7 @@
align-items: center;
position: relative;
z-index: 1;
margin-left: auto;
}
.header-icon-btn {
@ -1145,66 +1146,161 @@
}
}
/* ── Mobile bottom nav ── */
/* ── Hamburger menu button (mobile only) ── */
.mobile-bottom-nav {
position: fixed;
bottom: 0;
inset-inline: 0;
z-index: 100;
background-color: var(--t-surface-raised);
border-top: 1px solid var(--t-border-default);
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
padding-bottom: env(safe-area-inset-bottom, 0px);
& ul {
.header-hamburger {
display: flex;
justify-content: space-around;
align-items: center;
height: 4rem;
margin: 0;
padding: 0;
list-style: none;
}
& li {
flex: 1;
}
}
.mobile-nav-link {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding-block: 0.5rem;
margin-inline: 0.25rem;
min-height: 56px;
border-radius: var(--t-radius-card, 0.5rem);
font-size: var(--t-text-caption);
width: 2.5rem;
height: 2.5rem;
background: none;
border: none;
cursor: pointer;
color: var(--t-text-secondary);
text-decoration: none;
font-weight: 500;
background-color: transparent;
border-radius: var(--t-radius-button);
margin-right: 0.5rem;
flex-shrink: 0;
& svg {
width: 1.25rem;
height: 1.25rem;
&:hover {
background: var(--t-surface-sunken);
}
&[aria-current="page"] {
color: color-mix(in oklch, var(--t-accent) 80%, black);
font-weight: 600;
background-color: color-mix(in oklch, var(--t-accent) 10%, transparent);
& svg {
width: 1.5rem;
height: 1.5rem;
}
@media (min-width: 768px) {
display: none;
}
}
/* ── Mobile nav drawer ── */
.mobile-nav-drawer {
position: fixed;
inset: 0;
z-index: 1000;
pointer-events: none;
visibility: hidden;
}
.mobile-nav-drawer.is-open {
pointer-events: auto;
visibility: visible;
}
.mobile-nav-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity 0.2s ease;
}
.mobile-nav-drawer.is-open .mobile-nav-backdrop {
opacity: 1;
}
.mobile-nav-panel {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 280px;
max-width: 85vw;
background: var(--t-surface-base);
transform: translateX(-100%);
transition: transform 0.25s ease;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.mobile-nav-drawer.is-open .mobile-nav-panel {
transform: translateX(0);
}
.mobile-nav-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--t-border-default);
}
.mobile-nav-title {
font-family: var(--t-font-heading);
font-size: var(--t-text-lg);
font-weight: 600;
color: var(--t-text-primary);
}
.mobile-nav-close {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
background: none;
border: none;
cursor: pointer;
color: var(--t-text-secondary);
border-radius: var(--t-radius-button);
&:hover {
background: var(--t-surface-sunken);
}
}
.mobile-nav-links {
list-style: none;
margin: 0;
padding: 0.5rem 0;
}
.mobile-nav-drawer .mobile-nav-link {
display: block;
padding: 0.875rem 1.25rem;
color: var(--t-text-primary);
text-decoration: none;
font-size: var(--t-text-base);
font-weight: 500;
transition: background 0.15s ease;
&:hover {
background: var(--t-surface-sunken);
}
&[aria-current="page"] {
color: var(--t-accent);
background: color-mix(in oklch, var(--t-accent) 8%, transparent);
}
}
.mobile-nav-section {
padding-top: 0.5rem;
border-top: 1px solid var(--t-border-default);
margin-top: 0.5rem;
}
.mobile-nav-section-title {
display: block;
padding: 0.5rem 1.25rem;
font-size: var(--t-text-sm);
font-weight: 600;
color: var(--t-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Mobile bottom nav (REMOVED - replaced by hamburger drawer) ── */
.mobile-bottom-nav {
display: none;
}
/* ── Search modal ── */
.search-modal {
@ -2356,13 +2452,7 @@
/* ── Flash messages ── */
.shop-flash-group {
position: fixed;
top: 1rem;
inset-inline-end: 1rem;
z-index: 200;
display: flex;
flex-direction: column;
gap: 0.5rem;
/* document flow — pushes content down, no overlay */
}
.shop-flash {
@ -2370,10 +2460,6 @@
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: var(--t-radius-card);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
max-width: 24rem;
animation: flash-in 0.3s ease-out;
background-color: var(--t-surface-raised, #fff);
color: var(--t-text-primary);
@ -2384,11 +2470,11 @@
}
.shop-flash--info {
border: 1px solid var(--t-border-default);
border-bottom: 1px solid var(--t-border-default);
}
.shop-flash--error {
border: 1px solid hsl(0 70% 50% / 0.3);
border-bottom: 1px solid hsl(0 70% 50% / 0.3);
}
.shop-flash-icon {
@ -2405,11 +2491,6 @@
color: hsl(0 70% 50%);
}
@keyframes flash-in {
from { opacity: 0; transform: translateX(1rem); }
to { opacity: 1; transform: translateX(0); }
}
/* Transition classes for JS.hide flash dismiss */
.fade-out { transition: opacity 200ms ease-out; }
.fade-out-from { opacity: 1; }

View File

@ -478,6 +478,56 @@ const SearchModal = {
}
}
// Mobile nav drawer - slides in from the left
const MobileNavDrawer = {
mounted() {
this.el.addEventListener("open-mobile-nav", () => this.open())
this.el.addEventListener("close-mobile-nav", () => this.close())
// Close on Escape key
this._keydown = (e) => {
if (e.key === "Escape" && this.isOpen()) {
e.preventDefault()
this.close()
}
}
document.addEventListener("keydown", this._keydown)
// Prevent hamburger button navigation on no-JS fallback
this._hamburger = document.querySelector('.header-hamburger')
if (this._hamburger) {
this._preventNav = (e) => e.preventDefault()
this._hamburger.addEventListener("click", this._preventNav)
}
},
destroyed() {
document.removeEventListener("keydown", this._keydown)
if (this._hamburger && this._preventNav) {
this._hamburger.removeEventListener("click", this._preventNav)
}
},
isOpen() {
return this.el.classList.contains("is-open")
},
open() {
this.el.classList.add("is-open")
document.body.style.overflow = "hidden"
// Focus the close button for accessibility
const closeBtn = this.el.querySelector(".mobile-nav-close")
if (closeBtn) closeBtn.focus()
},
close() {
this.el.classList.remove("is-open")
document.body.style.overflow = ""
// Return focus to hamburger button
if (this._hamburger) this._hamburger.focus()
}
}
// Flex-wrap base → horizontal scroll enhancement for collection category pills.
// If the pills wrap past 2 rows on mobile, switches to single-row scroll
// and scrolls the active pill into view.
@ -629,6 +679,53 @@ const DirtyGuard = {
}
}
// EditorSheet: simple open/collapse sheet for page editing
// Positioning is handled by CSS - JS just handles click-outside and Escape
const EditorSheet = {
mounted() {
// Click outside to collapse (works in any mode for preview)
// Use mousedown instead of click to avoid race with LiveView re-renders
this._onDocMousedown = (e) => {
if (!this.el.contains(e.target) && this._getState() !== "collapsed") {
this._setState("collapsed")
}
}
document.addEventListener("mousedown", this._onDocMousedown)
// Escape key to collapse
this._onKeydown = (e) => {
if (e.key === "Escape" && this._getState() !== "collapsed") {
e.preventDefault()
this._setState("collapsed")
}
}
document.addEventListener("keydown", this._onKeydown)
},
destroyed() {
document.removeEventListener("mousedown", this._onDocMousedown)
document.removeEventListener("keydown", this._onKeydown)
},
_getState() {
return this.el.dataset.state || "collapsed"
},
_setState(state) {
this.el.dataset.state = state
this.el.setAttribute("aria-expanded", state !== "collapsed")
this.pushEvent("editor_set_sheet_state", { state })
this._announce(state === "collapsed" ? "Editor collapsed" : "Editor expanded")
},
_announce(message) {
const region = document.getElementById("editor-live-region")
if (region) {
region.textContent = message
}
}
}
// DirtyGuard + Ctrl+Z / Ctrl+Shift+Z undo/redo for page editors
const EditorKeyboard = {
mounted() {
@ -682,7 +779,7 @@ const EditorKeyboard = {
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken, screen_width: window.innerWidth},
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, AnalyticsInit, AnalyticsExport, ChartTooltip, DirtyGuard, EditorKeyboard},
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, MobileNavDrawer, CollectionFilters, AnalyticsInit, AnalyticsExport, ChartTooltip, DirtyGuard, EditorKeyboard, EditorSheet},
})
// Show progress bar on live navigation and form submits
@ -701,6 +798,12 @@ window.addEventListener("phx:scroll-preview-top", (e) => {
// connect if there are any LiveViews on the page
liveSocket.connect()
// Suppress connection error banners during intentional navigation (refresh, link click).
// beforeunload fires on navigation but NOT on genuine connection drops.
window.addEventListener("beforeunload", () => {
document.body.classList.add("navigating-away")
})
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session

View File

@ -16,6 +16,20 @@ defmodule Berrypod.Pages.Defaults do
|> Enum.map(&for_slug/1)
end
@doc "Returns true if the given blocks match the defaults for the slug."
def matches_defaults?(slug, current_blocks) when is_list(current_blocks) do
default_blocks = blocks(slug)
length(current_blocks) == length(default_blocks) and
Enum.zip(current_blocks, default_blocks)
|> Enum.all?(fn {current, default} ->
current["type"] == default["type"] and
current["settings"] == default["settings"]
end)
end
def matches_defaults?(_slug, _blocks), do: false
# ── Page titles ─────────────────────────────────────────────────
defp title("home"), do: "Home page"

View File

@ -47,7 +47,7 @@ defmodule BerrypodWeb.CoreComponents do
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="admin-toast"
class="admin-banner"
{@rest}
>
<div class={[
@ -70,6 +70,39 @@ defmodule BerrypodWeb.CoreComponents do
"""
end
@doc """
Renders inline status feedback next to a button or form section.
## Examples
<.inline_feedback status={@save_status} />
<.inline_feedback status={@save_status} message={@save_error} />
"""
attr :status, :atom, values: [:idle, :saving, :saved, :error], default: :idle
attr :message, :string, default: nil
def inline_feedback(assigns) do
~H"""
<span
:if={@status != :idle}
class={["admin-inline-feedback", "admin-inline-feedback-#{@status}"]}
role={@status == :error && "alert"}
>
<.icon :if={@status == :saving} name="hero-arrow-path" class="size-4 motion-safe:animate-spin" />
<.icon :if={@status == :saved} name="hero-check" class="size-4" />
<.icon :if={@status == :error} name="hero-exclamation-circle" class="size-4" />
<span>{feedback_text(@status, @message)}</span>
</span>
"""
end
defp feedback_text(:saving, _), do: "Saving..."
defp feedback_text(:saved, nil), do: "Saved"
defp feedback_text(:saved, msg), do: msg
defp feedback_text(:error, nil), do: "Something went wrong"
defp feedback_text(:error, msg), do: msg
defp feedback_text(:idle, _), do: nil
@doc """
Renders a button with navigation support.

View File

@ -35,13 +35,13 @@ defmodule BerrypodWeb.Layouts do
def app(assigns) do
~H"""
<.flash_group flash={@flash} />
<main class="app-main">
<div class="app-container">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end

View File

@ -18,9 +18,10 @@
</.link>
</header>
<.flash_group flash={@flash} />
<%!-- page content --%>
<main class="admin-main">
<.flash_group flash={@flash} />
<div class="admin-container">
{@inner_content}
</div>

View File

@ -128,9 +128,6 @@ defmodule BerrypodWeb.ShopComponents.Layout do
mode={@mode}
cart_count={@cart_count}
is_admin={@is_admin}
editing={@editing}
editor_current_path={@editor_current_path}
editor_sidebar_open={@editor_sidebar_open}
header_nav_items={@header_nav_items}
/>
@ -167,11 +164,12 @@ defmodule BerrypodWeb.ShopComponents.Layout do
search_open={@search_open}
/>
<.mobile_bottom_nav
<.mobile_nav_drawer
:if={!@error_page}
active_page={@active_page}
mode={@mode}
items={@header_nav_items}
categories={assigns[:categories] || []}
/>
</div>
"""
@ -513,6 +511,111 @@ defmodule BerrypodWeb.ShopComponents.Layout do
"""
end
@doc """
Renders the mobile navigation drawer.
A slide-out drawer containing the main navigation links for mobile users.
Triggered by the hamburger menu button in the header.
"""
attr :active_page, :string, required: true
attr :mode, :atom, default: :live
attr :items, :list, default: []
attr :categories, :list, default: []
def mobile_nav_drawer(assigns) do
~H"""
<div
id="mobile-nav-drawer"
class="mobile-nav-drawer"
phx-hook="MobileNavDrawer"
>
<div
class="mobile-nav-backdrop"
phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")}
>
</div>
<nav class="mobile-nav-panel" aria-label="Main navigation">
<div class="mobile-nav-header">
<span class="mobile-nav-title">Menu</span>
<button
type="button"
class="mobile-nav-close"
phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")}
aria-label="Close menu"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<ul class="mobile-nav-links">
<li :for={item <- @items}>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page={item["slug"]}
class="mobile-nav-link"
aria-current={@active_page in (item["active_slugs"] || [item["slug"]]) && "page"}
>
{item["label"]}
</a>
<% else %>
<.link
navigate={item["href"]}
class="mobile-nav-link"
aria-current={@active_page in (item["active_slugs"] || [item["slug"]]) && "page"}
phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")}
>
{item["label"]}
</.link>
<% end %>
</li>
</ul>
<%= if @categories != [] do %>
<div class="mobile-nav-section">
<span class="mobile-nav-section-title">Shop by category</span>
<ul class="mobile-nav-links">
<li :for={category <- @categories}>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="collection"
class="mobile-nav-link"
>
{category.name}
</a>
<% else %>
<.link
navigate={"/collections/#{category.slug}"}
class="mobile-nav-link"
phx-click={
Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")
}
>
{category.name}
</.link>
<% end %>
</li>
</ul>
</div>
<% end %>
</nav>
</div>
"""
end
@doc """
Renders the shop footer with newsletter signup and links.
@ -662,9 +765,6 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :mode, :atom, default: :live
attr :cart_count, :integer, default: 0
attr :is_admin, :boolean, default: false
attr :editing, :boolean, default: false
attr :editor_current_path, :string, default: nil
attr :editor_sidebar_open, :boolean, default: true
attr :header_nav_items, :list, default: []
def shop_header(assigns) do
@ -674,6 +774,27 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<div style={header_background_style(@theme_settings, @header_image)} />
<% end %>
<%!-- Hamburger menu button (mobile only) --%>
<button
type="button"
class="header-hamburger"
phx-click={Phoenix.LiveView.JS.dispatch("open-mobile-nav", to: "#mobile-nav-drawer")}
aria-label="Open menu"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<div class="shop-logo">
<.logo_content
theme_settings={@theme_settings}
@ -697,48 +818,14 @@ defmodule BerrypodWeb.ShopComponents.Layout do
</nav>
<div class="shop-actions">
<%!-- Pencil icon: enters edit mode, or re-opens sidebar if already editing --%>
<.link
:if={@is_admin && !@editing && @editor_current_path}
patch={"#{@editor_current_path}?edit=true"}
class="header-icon-btn"
aria-label="Edit page"
>
<.edit_pencil_svg />
</.link>
<button
:if={@is_admin && @editing && !@editor_sidebar_open}
phx-click="editor_toggle_sidebar"
class="header-icon-btn"
aria-label="Show editor sidebar"
>
<.edit_pencil_svg />
</button>
<%!-- Admin cog: always visible for admins, links to admin dashboard --%>
<.link
:if={@is_admin}
href="/admin"
class="header-icon-btn"
aria-label="Admin"
aria-label="Admin dashboard"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
<.admin_cog_svg />
</.link>
<a
href="/search"
@ -950,4 +1037,197 @@ defmodule BerrypodWeb.ShopComponents.Layout do
defp open_cart_drawer_js do
Phoenix.LiveView.JS.push("open_cart_drawer")
end
# ── Editor sheet ────────────────────────────────────────────────────
@doc """
Renders the unified editor sheet for page 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)
and two states on desktop (collapsed, open).
## Attributes
* `editing` - Whether edit mode is active.
* `editor_dirty` - Whether there are unsaved changes.
* `editor_sheet_state` - Current state (:collapsed, :partial, :full, or :open).
## Slots
* `inner_block` - The editor content (block list, settings, etc.).
"""
attr :editing, :boolean, default: false
attr :editor_dirty, :boolean, default: false
attr :editor_sheet_state, :atom, default: :collapsed
attr :editor_save_status, :atom, default: :idle
slot :inner_block
def editor_sheet(assigns) do
~H"""
<aside
id="editor-sheet"
class="editor-sheet"
role="region"
aria-label="Page editor"
aria-expanded={to_string(@editor_sheet_state != :collapsed)}
data-state={@editor_sheet_state}
data-editing={to_string(@editing)}
phx-hook="EditorSheet"
>
<%!-- Header: content varies by state and editing mode --%>
<div class="editor-sheet-header">
<%= if @editor_sheet_state == :collapsed and not @editing do %>
<%!-- Not editing, collapsed: show Edit button to enter edit mode --%>
<button
type="button"
phx-click="editor_toggle_editing"
class="editor-sheet-edit-btn"
>
<.edit_pencil_svg />
<span>Edit page</span>
</button>
<% end %>
<%= if @editor_sheet_state == :collapsed and @editing do %>
<%!-- Editing but collapsed: show button to expand sheet (for previewing) --%>
<button
type="button"
phx-click="editor_set_sheet_state"
phx-value-state="open"
class="editor-sheet-edit-btn"
>
<.edit_pencil_svg />
<span>Show editor</span>
</button>
<span :if={@editor_dirty} class="editor-sheet-dirty" aria-live="polite">
<span class="editor-sheet-dirty-dot" aria-hidden="true" />
<span>Unsaved</span>
</span>
<% end %>
<%= if @editor_sheet_state != :collapsed do %>
<div class="editor-sheet-header-left">
<span class="editor-sheet-title">Page editor</span>
<span :if={@editor_dirty} class="editor-sheet-dirty" aria-live="polite">
<span class="editor-sheet-dirty-dot" aria-hidden="true" />
<span>Unsaved</span>
</span>
</div>
<div class="editor-sheet-header-actions">
<button
:if={@editor_save_status == :saved}
type="button"
class="admin-btn admin-btn-sm admin-btn-ghost"
disabled
>
Saved
</button>
<button
:if={@editor_save_status != :saved}
type="button"
phx-click="editor_save"
class={["admin-btn admin-btn-sm", @editor_dirty && "admin-btn-primary"]}
disabled={!@editor_dirty}
>
Save
</button>
</div>
<% end %>
</div>
<%!-- Content area (hidden when collapsed) --%>
<div class="editor-sheet-content">
{render_slot(@inner_block)}
</div>
</aside>
<%!-- Live region for screen reader announcements --%>
<div id="editor-live-region" class="sr-only" aria-live="polite" aria-atomic="true" />
"""
end
# ── Admin rail (deprecated) ────────────────────────────────────────
@doc """
Renders the admin rail with edit and admin icons.
This thin vertical bar appears on the left edge of the page for logged-in admins.
The edit button toggles the page editor, and the cog links to the admin dashboard.
"""
attr :editing, :boolean, default: false
attr :editor_dirty, :boolean, default: false
attr :editor_sidebar_open, :boolean, default: true
slot :editor_sidebar
slot :inner_block, required: true
def admin_rail(assigns) do
~H"""
<div
class="admin-rail-layout"
data-editing={to_string(@editing)}
data-sidebar-open={to_string(@editor_sidebar_open)}
>
<aside class="admin-rail" aria-label="Admin tools">
<button
type="button"
phx-click="editor_toggle_editing"
class={["admin-rail-btn", @editing && "admin-rail-btn-active"]}
aria-label={if @editing, do: "Close editor", else: "Edit page"}
aria-pressed={to_string(@editing)}
>
<.edit_pencil_svg />
<span
:if={@editing && @editor_dirty}
class="admin-rail-dirty-dot"
aria-label="Unsaved changes"
/>
</button>
<.link href="/admin" class="admin-rail-btn" aria-label="Admin dashboard">
<.admin_cog_svg />
</.link>
</aside>
<aside :if={@editing} class="admin-rail-sidebar" aria-label="Page editor">
{render_slot(@editor_sidebar)}
</aside>
<%!-- Backdrop to close sidebar on mobile --%>
<div
:if={@editing && @editor_sidebar_open}
class="admin-rail-backdrop"
phx-click="editor_toggle_sidebar"
aria-hidden="true"
/>
<div class="admin-rail-content">
{render_slot(@inner_block)}
</div>
</div>
"""
end
def admin_cog_svg(assigns) do
~H"""
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
"""
end
end

View File

@ -16,10 +16,10 @@ defmodule BerrypodWeb.PageEditorHook do
"""
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [attach_hook: 4, put_flash: 3, push_navigate: 2]
import Phoenix.LiveView, only: [attach_hook: 4, push_navigate: 2]
alias Berrypod.Pages
alias Berrypod.Pages.{BlockEditor, BlockTypes}
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
def on_mount(:mount_page_editor, _params, _session, socket) do
socket =
@ -27,6 +27,7 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editing, false)
|> assign(:editing_blocks, nil)
|> assign(:editor_dirty, false)
|> assign(:editor_at_defaults, true)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
|> assign(:editor_expanded, MapSet.new())
@ -36,10 +37,12 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editor_live_region_message, nil)
|> assign(:editor_current_path, 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, [])
|> assign(:editor_image_picker_search, "")
|> assign(:editor_save_status, :idle)
|> 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)
@ -47,50 +50,48 @@ defmodule BerrypodWeb.PageEditorHook do
{:cont, socket}
end
# ── handle_params: detect ?edit=true ─────────────────────────────
# ── handle_params: track current path ────────────────────────────
defp handle_editor_params(_params, uri, socket) do
parsed = URI.parse(uri)
query = URI.decode_query(parsed.query || "")
wants_edit = query["edit"] == "true"
# Always store the current path for the edit button and "done" navigation
socket = assign(socket, :editor_current_path, parsed.path)
cond do
wants_edit and socket.assigns.is_admin and socket.assigns[:page] ->
# Page already loaded — enter edit mode and halt (no need for module handle_params)
{:halt, enter_edit_mode(socket)}
wants_edit and socket.assigns.is_admin ->
# Page not loaded yet (e.g. Shop.Content loads in handle_params),
# defer initialisation until after the LiveView sets @page
send(self(), :editor_deferred_init)
{:cont, assign(socket, :editing, true)}
socket.assigns.editing and not wants_edit ->
# Exiting edit mode — halt since we've handled the transition
{:halt, exit_edit_mode(socket)}
true ->
{:cont, socket}
end
# Store the current path for reference (e.g. the Done button)
{:cont, assign(socket, :editor_current_path, parsed.path)}
end
# ── handle_info: deferred init ───────────────────────────────────
# ── handle_info ─────────────────────────────────────────────────
defp handle_editor_info(:editor_deferred_init, socket) do
if socket.assigns.editing and is_nil(socket.assigns.editing_blocks) and socket.assigns[:page] do
{:halt, enter_edit_mode(socket)}
else
{:cont, socket}
end
defp handle_editor_info(:editor_clear_save_status, socket) do
{:halt, assign(socket, :editor_save_status, :idle)}
end
defp handle_editor_info(_msg, socket), do: {:cont, socket}
# ── handle_event: editor_* events ────────────────────────────────
# toggle_editing can be called even when not editing (to enter edit mode)
defp handle_editor_event("editor_toggle_editing", _params, socket) do
if socket.assigns.is_admin and socket.assigns[:page] do
if socket.assigns.editing do
{:halt, exit_edit_mode(socket)}
else
{:halt, enter_edit_mode(socket)}
end
else
{:cont, socket}
end
end
# 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
state = if state_str == "open", do: :open, else: :collapsed
{:halt, assign(socket, :editor_sheet_state, state)}
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)
@ -330,6 +331,7 @@ defmodule BerrypodWeb.PageEditorHook do
case socket.assigns.editor_history do
[prev | rest] ->
future = [socket.assigns.editing_blocks | socket.assigns.editor_future]
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, prev)
socket =
socket
@ -337,6 +339,7 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editor_history, rest)
|> assign(:editor_future, future)
|> assign(:editor_dirty, true)
|> assign(:editor_at_defaults, at_defaults)
|> assign(:editor_live_region_message, "Undone")
|> reload_block_data(prev)
@ -351,6 +354,7 @@ defmodule BerrypodWeb.PageEditorHook do
case socket.assigns.editor_future do
[next | rest] ->
history = [socket.assigns.editing_blocks | socket.assigns.editor_history]
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, next)
socket =
socket
@ -358,6 +362,7 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editor_history, history)
|> assign(:editor_future, rest)
|> assign(:editor_dirty, true)
|> assign(:editor_at_defaults, at_defaults)
|> assign(:editor_live_region_message, "Redone")
|> reload_block_data(next)
@ -376,39 +381,32 @@ defmodule BerrypodWeb.PageEditorHook do
case Pages.save_page(page.slug, %{title: page.title, blocks: blocks}) do
{:ok, _saved_page} ->
updated_page = Pages.get_page(page.slug)
at_defaults = Defaults.matches_defaults?(page.slug, updated_page.blocks)
Process.send_after(self(), :editor_clear_save_status, 2500)
socket =
socket
|> assign(:page, updated_page)
|> assign(:editing_blocks, updated_page.blocks)
|> assign(:editor_dirty, false)
|> assign(:editor_at_defaults, at_defaults)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
|> put_flash(:info, "Page saved")
|> assign(:editor_save_status, :saved)
{:halt, socket}
{:error, _changeset} ->
{:halt, put_flash(socket, :error, "Failed to save page")}
{:halt, assign(socket, :editor_save_status, :error)}
end
end
defp handle_editor_action("reset_defaults", _params, socket) do
slug = socket.assigns.page.slug
:ok = Pages.reset_page(slug)
page = Pages.get_page(slug)
default_blocks = Berrypod.Pages.Defaults.for_slug(slug).blocks
socket =
socket
|> assign(:page, page)
|> assign(:editing_blocks, page.blocks)
|> assign(:editor_dirty, false)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
|> reload_block_data(page.blocks)
|> put_flash(:info, "Page reset to defaults")
{:halt, socket}
# Treat reset like any other mutation: push to history, mark dirty
{:halt, apply_mutation(socket, default_blocks, "Reset to defaults", :content)}
end
defp handle_editor_action("done", _params, socket) do
@ -424,11 +422,13 @@ defmodule BerrypodWeb.PageEditorHook do
defp enter_edit_mode(socket) do
page = socket.assigns.page
allowed = BlockTypes.allowed_for(page.slug)
at_defaults = Defaults.matches_defaults?(page.slug, page.blocks)
socket
|> assign(:editing, true)
|> assign(:editing_blocks, page.blocks)
|> assign(:editor_dirty, false)
|> assign(:editor_at_defaults, at_defaults)
|> assign(:editor_history, [])
|> assign(:editor_future, [])
|> assign(:editor_expanded, MapSet.new())
@ -437,10 +437,12 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editor_allowed_blocks, allowed)
|> assign(:editor_live_region_message, nil)
|> assign(:editor_sidebar_open, true)
|> assign(:editor_sheet_state, :open)
|> assign(:editor_image_picker_block_id, nil)
|> assign(:editor_image_picker_field_key, nil)
|> assign(:editor_image_picker_images, [])
|> assign(:editor_image_picker_search, "")
|> assign(:editor_save_status, :idle)
end
defp exit_edit_mode(socket) do
@ -456,22 +458,27 @@ 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, [])
|> assign(:editor_image_picker_search, "")
|> assign(:editor_save_status, :idle)
end
defp apply_mutation(socket, new_blocks, message, type) do
history =
[socket.assigns.editing_blocks | socket.assigns.editor_history] |> Enum.take(50)
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, new_blocks)
socket =
socket
|> assign(:editing_blocks, new_blocks)
|> assign(:editor_history, history)
|> assign(:editor_future, [])
|> assign(:editor_dirty, true)
|> assign(:editor_at_defaults, at_defaults)
|> assign(:editor_live_region_message, message)
case type do

View File

@ -32,16 +32,16 @@ defmodule BerrypodWeb.PageRenderer do
live page editor), wraps the page in a sidebar + content layout.
"""
def render_page(assigns) do
if assigns[:editing] && assigns[:editing_blocks] do
render_page_with_editor(assigns)
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
if assigns[:is_admin] do
render_page_with_rail(assigns)
else
render_page_normal(assigns)
end
end
defp render_page_normal(assigns) do
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
~H"""
<.shop_layout
{layout_assigns(assigns)}
@ -57,22 +57,80 @@ defmodule BerrypodWeb.PageRenderer do
"""
end
defp render_page_with_editor(assigns) do
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
defp render_page_with_rail(assigns) do
~H"""
<div
id="page-editor-live"
class="page-editor-live"
phx-hook="EditorKeyboard"
data-dirty={to_string(@editor_dirty)}
data-event-prefix="editor_"
data-sidebar-open={to_string(@editor_sidebar_open)}
<.shop_layout
{layout_assigns(assigns)}
active_page={@page.slug}
error_page={@page.slug == "error"}
>
<aside class="page-editor-sidebar" aria-label="Page editor">
<div class="page-editor-sidebar-header">
<h2 class="page-editor-sidebar-title">{@page.title}</h2>
<div class="page-editor-sidebar-actions">
<main id="main-content" class={page_main_class(@page.slug)}>
<%= if @editing && @editing_blocks do %>
<div
:for={block <- @editing_blocks}
:key={block["id"]}
data-block-type={block["type"]}
>
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
</div>
<% else %>
<div :for={block <- @page.blocks} :key={block["id"]} data-block-type={block["type"]}>
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
</div>
<% end %>
</main>
</.shop_layout>
<%!-- Editor sheet for page editing --%>
<.editor_sheet
editing={@editing}
editor_dirty={@editor_dirty}
editor_sheet_state={assigns[:editor_sheet_state] || :collapsed}
editor_save_status={@editor_save_status}
>
<.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={Map.get(assigns, :editor_at_defaults, true)}
/>
</.editor_sheet>
"""
end
# Editor sheet content - the block list and editing controls
attr :page, :map, required: true
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
defp editor_sheet_content(assigns) do
~H"""
<div id="editor-sheet-inner" phx-hook="EditorKeyboard" data-dirty={to_string(@editor_dirty)}>
<%!-- Page title and undo/redo --%>
<div class="editor-sheet-page-header">
<h3 class="editor-sheet-page-title">{@page.title}</h3>
<div class="editor-sheet-undo-redo">
<button
phx-click="editor_undo"
class={[
@ -95,30 +153,6 @@ defmodule BerrypodWeb.PageRenderer do
>
<.icon name="hero-arrow-uturn-right" class="size-4" />
</button>
<button
phx-click="editor_save"
class={[
"admin-btn admin-btn-sm admin-btn-primary",
!@editor_dirty && "opacity-50"
]}
disabled={!@editor_dirty}
>
Save
</button>
<button
phx-click="editor_reset_defaults"
data-confirm="Reset this page to its default layout? Your changes will be lost."
class="admin-btn admin-btn-sm admin-btn-ghost"
>
Reset
</button>
<button
phx-click="editor_done"
class="admin-btn admin-btn-sm admin-btn-ghost"
data-confirm={@editor_dirty && "You have unsaved changes. Leave without saving?"}
>
Done
</button>
</div>
</div>
@ -127,23 +161,18 @@ defmodule BerrypodWeb.PageRenderer do
{if @editor_live_region_message, do: @editor_live_region_message}
</div>
<%!-- Unsaved changes indicator --%>
<p :if={@editor_dirty} class="admin-badge admin-badge-warning page-editor-sidebar-dirty">
Unsaved changes
</p>
<%!-- Block list --%>
<div class="block-list" role="list" aria-label="Page blocks">
<.block_card
:for={{block, idx} <- Enum.with_index(@editing_blocks)}
:for={{block, idx} <- Enum.with_index(@editing_blocks || [])}
block={block}
idx={idx}
total={length(@editing_blocks)}
expanded={@editor_expanded}
total={length(@editing_blocks || [])}
expanded={@editor_expanded || MapSet.new()}
event_prefix="editor_"
/>
<div :if={@editing_blocks == []} class="block-list-empty">
<div :if={(@editing_blocks || []) == []} class="block-list-empty">
<p>No blocks on this page yet.</p>
</div>
</div>
@ -153,6 +182,14 @@ defmodule BerrypodWeb.PageRenderer do
<button phx-click="editor_show_picker" class="admin-btn admin-btn-outline block-add-btn">
<.icon name="hero-plus" class="size-4" /> Add block
</button>
<button
phx-click="editor_reset_defaults"
data-confirm="Reset this page to its default blocks? You can undo this."
class="admin-btn admin-btn-sm admin-btn-ghost"
disabled={@editor_at_defaults}
>
<.icon name="hero-arrow-path" class="size-4" /> Reset to defaults
</button>
</div>
<%!-- Block picker modal --%>
@ -170,33 +207,6 @@ defmodule BerrypodWeb.PageRenderer do
search={@editor_image_picker_search}
event_prefix="editor_"
/>
</aside>
<%!-- Backdrop: tapping the page dismisses the sidebar --%>
<div
:if={@editor_sidebar_open}
class="page-editor-backdrop"
phx-click="editor_toggle_sidebar"
aria-hidden="true"
/>
<div class="page-editor-content">
<.shop_layout
{layout_assigns(assigns)}
active_page={@page.slug}
error_page={@page.slug == "error"}
>
<main id="main-content" class={page_main_class(@page.slug)}>
<div
:for={block <- @editing_blocks}
:key={block["id"]}
data-block-type={block["type"]}
>
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
</div>
</main>
</.shop_layout>
</div>
</div>
"""
end

View File

@ -120,10 +120,18 @@ defmodule BerrypodWeb.Shop.CustomPageTest do
:ok
end
test "editing works with ?edit=true", %{conn: conn, user: user} do
test "editing works with edit toggle", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, "/editable?edit=true")
assert has_element?(view, ".page-editor-sidebar")
{:ok, view, _html} = live(conn, "/editable")
# Editor sheet should be visible for admins
assert has_element?(view, ".editor-sheet")
# Click the edit button in the sheet to enter edit mode
view |> element(".editor-sheet-edit-btn") |> render_click()
# Now the editor sheet content should be visible (sheet state changes to open)
assert has_element?(view, ".editor-sheet-content")
end
end
end

View File

@ -29,97 +29,75 @@ defmodule BerrypodWeb.PageEditorHookTest do
end
describe "non-admin cannot access edit mode" do
test "visiting with ?edit=true shows normal page, no sidebar", %{conn: conn} do
{:ok, _view, html} = live(conn, "/?edit=true")
refute html =~ "page-editor-sidebar"
refute html =~ "page-editor-live"
end
end
describe "edit button visibility" do
test "admin sees edit pencil in shop header", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, "/")
assert html =~ "Edit page"
end
test "non-admin does not see edit pencil", %{conn: conn} do
test "editor sheet is not shown for non-admins", %{conn: conn} do
{:ok, _view, html} = live(conn, "/")
refute html =~ "editor-sheet"
refute html =~ "Edit page"
end
end
describe "editor sheet visibility" do
test "admin sees editor sheet with edit button", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, "/")
assert has_element?(view, ".editor-sheet")
assert has_element?(view, "button", "Edit page")
end
test "non-admin does not see editor sheet", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
refute has_element?(view, ".editor-sheet")
refute has_element?(view, "button", "Edit page")
end
end
describe "entering and exiting edit mode" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "admin enters edit mode with ?edit=true", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
test "clicking edit button enters edit mode", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert has_element?(view, ".page-editor-sidebar")
assert has_element?(view, ".page-editor-content")
# Sheet starts collapsed
assert has_element?(view, ".editor-sheet[data-state='collapsed']")
# Click edit button (the specific edit button class)
view |> element(".editor-sheet-edit-btn") |> render_click()
# Now editing, sheet expanded
assert has_element?(view, ".editor-sheet[data-editing='true']")
assert has_element?(view, ".editor-sheet-content")
assert has_element?(view, ".block-card")
end
test "sidebar shows the page title", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
test "sheet shows the page title when editing", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
assert has_element?(view, ".page-editor-sidebar-title", "Home page")
view |> element(".editor-sheet-edit-btn") |> render_click()
assert has_element?(view, ".editor-sheet-page-title", "Home page")
end
test "done button exits edit mode", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
test "sheet state changes when entering edit mode and collapsing", %{conn: conn} do
{:ok, view, _html} = live(conn, "/")
# editor_done uses push_navigate, which causes a redirect
{:error, {:live_redirect, %{to: "/"}}} =
view |> element("button[phx-click='editor_done']") |> render_click()
end
# Starts collapsed
assert has_element?(view, ".editor-sheet[data-state='collapsed']")
test "toggle sidebar hides and shows via header pencil", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
# Enter edit mode - expands to open
view |> element(".editor-sheet-edit-btn") |> render_click()
assert has_element?(view, ".editor-sheet[data-state='open']")
# Sidebar starts open, pencil button in header is hidden
assert has_element?(view, "[data-sidebar-open='true']")
# Collapse sheet (still in edit mode, just previewing)
render_click(view, "editor_set_sheet_state", %{"state" => "collapsed"})
refute has_element?(
view,
"button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']"
)
# Close the sidebar via the backdrop
view |> element(".page-editor-backdrop") |> render_click()
assert has_element?(view, "[data-sidebar-open='false']")
# Pencil button appears in header to re-open
assert has_element?(
view,
"button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']"
)
# Re-open via pencil in header
view
|> element("button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']")
|> render_click()
assert has_element?(view, "[data-sidebar-open='true']")
end
test "clicking backdrop hides the sidebar", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
# Backdrop present when sidebar is open
assert has_element?(view, ".page-editor-backdrop")
# Click backdrop to dismiss
view |> element(".page-editor-backdrop") |> render_click()
assert has_element?(view, "[data-sidebar-open='false']")
# Backdrop gone when sidebar is hidden
refute has_element?(view, ".page-editor-backdrop")
assert has_element?(view, ".editor-sheet[data-state='collapsed']")
# Still in edit mode
assert has_element?(view, ".editor-sheet[data-editing='true']")
end
end
@ -129,7 +107,10 @@ defmodule BerrypodWeb.PageEditorHookTest do
end
test "move block down reorders the list", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
{:ok, view, _html} = live(conn, "/")
# Enter edit mode
view |> element(".editor-sheet-edit-btn") |> render_click()
# Home page default: hero is first block
first_card = view |> element(".block-card:first-child")
@ -150,9 +131,12 @@ defmodule BerrypodWeb.PageEditorHookTest do
end
test "dirty indicator appears after changes", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
{:ok, view, _html} = live(conn, "/")
refute has_element?(view, ".admin-badge-warning", "Unsaved changes")
# Enter edit mode
view |> element(".editor-sheet-edit-btn") |> render_click()
refute has_element?(view, ".editor-sheet-dirty")
# Move a block to trigger dirty state
blocks = Pages.get_page("home").blocks
@ -162,7 +146,7 @@ defmodule BerrypodWeb.PageEditorHookTest do
|> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
|> render_click()
assert has_element?(view, ".admin-badge-warning", "Unsaved changes")
assert has_element?(view, ".editor-sheet-dirty")
end
end
@ -172,7 +156,10 @@ defmodule BerrypodWeb.PageEditorHookTest do
end
test "save persists block changes", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
{:ok, view, _html} = live(conn, "/")
# Enter edit mode
view |> element(".editor-sheet-edit-btn") |> render_click()
# Move a block to make changes
blocks = Pages.get_page("home").blocks
@ -186,30 +173,43 @@ defmodule BerrypodWeb.PageEditorHookTest do
# Save
view |> element("button[phx-click='editor_save']") |> render_click()
assert has_element?(view, "#shop-flash-info", "Page saved")
# Verify persistence
updated = Pages.get_page("home")
refute List.first(updated.blocks)["type"] == original_first_type
end
test "reset restores default blocks", %{conn: conn} do
# First, save a modified page
test "reset restores default blocks and is undoable", %{conn: conn} do
# First, save a modified page (reverse block order)
original = Pages.get_page("home")
reordered = Enum.reverse(original.blocks)
Pages.save_page("home", %{title: original.title, blocks: reordered})
PageCache.invalidate_all()
{:ok, view, _html} = live(conn, "/?edit=true")
{:ok, view, _html} = live(conn, "/")
# Enter edit mode
view |> element(".editor-sheet-edit-btn") |> render_click()
# Get the first block type before reset (should be reversed, so last default)
first_before_reset = view |> element(".block-card:first-child") |> render()
# Reset
view |> element("button[phx-click='editor_reset_defaults']") |> render_click()
assert has_element?(view, "#shop-flash-info", "Page reset to defaults")
# First block should now be the default first (Hero)
first_after_reset = view |> element(".block-card:first-child") |> render()
assert first_after_reset =~ "Hero"
refute first_after_reset == first_before_reset
# Verify the blocks are back to defaults
reset_page = Pages.get_page("home")
assert List.first(reset_page.blocks)["type"] == List.first(original.blocks)["type"]
# Reset should be undoable
refute has_element?(view, "button[phx-click='editor_undo'][disabled]")
# Undo the reset
view |> element("button[phx-click='editor_undo']") |> render_click()
# Should be back to the reversed order
first_after_undo = view |> element(".block-card:first-child") |> render()
refute first_after_undo =~ "Hero"
end
end
@ -219,7 +219,10 @@ defmodule BerrypodWeb.PageEditorHookTest do
end
test "undo reverts the last change", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
{:ok, view, _html} = live(conn, "/")
# Enter edit mode
view |> element(".editor-sheet-edit-btn") |> render_click()
blocks = Pages.get_page("home").blocks
second_id = Enum.at(blocks, 1)["id"]
@ -241,7 +244,10 @@ defmodule BerrypodWeb.PageEditorHookTest do
end
test "redo restores an undone change", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
{:ok, view, _html} = live(conn, "/")
# Enter edit mode
view |> element(".editor-sheet-edit-btn") |> render_click()
blocks = Pages.get_page("home").blocks
second_id = Enum.at(blocks, 1)["id"]
@ -259,7 +265,10 @@ defmodule BerrypodWeb.PageEditorHookTest do
end
test "history clears on save", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
{:ok, view, _html} = live(conn, "/")
# Enter edit mode
view |> element(".editor-sheet-edit-btn") |> render_click()
blocks = Pages.get_page("home").blocks
second_id = Enum.at(blocks, 1)["id"]
@ -275,7 +284,10 @@ defmodule BerrypodWeb.PageEditorHookTest do
end
test "undo/redo buttons reflect stack state", %{conn: conn} do
{:ok, view, _html} = live(conn, "/?edit=true")
{:ok, view, _html} = live(conn, "/")
# Enter edit mode
view |> element(".editor-sheet-edit-btn") |> render_click()
# Initially both disabled
assert has_element?(view, "button[phx-click='editor_undo'][disabled]")
@ -295,16 +307,22 @@ defmodule BerrypodWeb.PageEditorHookTest do
end
end
describe "content pages (deferred init)" do
describe "content pages" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "editing works on about page via deferred init", %{conn: conn} do
{:ok, view, _html} = live(conn, "/about?edit=true")
test "editing works on about page", %{conn: conn} do
{:ok, view, _html} = live(conn, "/about")
assert has_element?(view, ".page-editor-sidebar")
assert has_element?(view, ".page-editor-sidebar-title", "About")
# Editor sheet visible for admin
assert has_element?(view, ".editor-sheet")
# Enter edit mode
view |> element(".editor-sheet-edit-btn") |> render_click()
assert has_element?(view, ".editor-sheet-content")
assert has_element?(view, ".editor-sheet-page-title", "About")
assert has_element?(view, ".block-card")
end
end