replace admin rail with unified bottom sheet editor
All checks were successful
deploy / deploy (push) Successful in 1m30s
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:
parent
dbcecc7878
commit
f4f036b84b
@ -631,33 +631,23 @@
|
|||||||
|
|
||||||
/* ── Feedback ── */
|
/* ── Feedback ── */
|
||||||
|
|
||||||
.admin-toast {
|
.admin-banner {
|
||||||
position: fixed;
|
/* document flow — pushes content down, no overlay */
|
||||||
top: 1rem;
|
|
||||||
inset-inline-end: 1rem;
|
|
||||||
z-index: 100;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide connection error banners during intentional navigation */
|
||||||
|
.navigating-away #flash-group { display: none; }
|
||||||
|
|
||||||
.admin-alert {
|
.admin-alert {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
width: 20rem;
|
|
||||||
max-width: 20rem;
|
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.admin-alert {
|
|
||||||
width: 24rem;
|
|
||||||
max-width: 24rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-alert-title {
|
.admin-alert-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@ -669,13 +659,13 @@
|
|||||||
.admin-alert-info {
|
.admin-alert-info {
|
||||||
background-color: color-mix(in oklch, var(--t-status-info) 12%, var(--t-surface-base));
|
background-color: color-mix(in oklch, var(--t-status-info) 12%, var(--t-surface-base));
|
||||||
color: var(--t-status-info);
|
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 {
|
.admin-alert-error {
|
||||||
background-color: color-mix(in oklch, var(--t-status-error) 12%, var(--t-surface-base));
|
background-color: color-mix(in oklch, var(--t-status-error) 12%, var(--t-surface-base));
|
||||||
color: var(--t-status-error);
|
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 {
|
.admin-alert-close {
|
||||||
@ -686,6 +676,27 @@
|
|||||||
&:hover { opacity: 0.7; }
|
&: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 {
|
.admin-banner-warning {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -2020,8 +2031,12 @@
|
|||||||
.block-list {
|
.block-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-top: 0;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 1.5rem;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-list-empty {
|
.block-list-empty {
|
||||||
@ -2034,35 +2049,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.block-card {
|
.block-card {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.375rem 0.5rem;
|
||||||
border: 1px solid var(--t-border-default);
|
border: 1px solid var(--t-border-default);
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.375rem;
|
||||||
background: var(--t-surface-base);
|
background: var(--t-surface-base);
|
||||||
transition: box-shadow 150ms;
|
transition: box-shadow 150ms;
|
||||||
|
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
box-shadow: 0 0 0 2px color-mix(in oklch, var(--t-text-primary) 20%, transparent);
|
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 {
|
.block-card-position {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 1.5rem;
|
width: 1.25rem;
|
||||||
height: 1.5rem;
|
height: 1.25rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--admin-text-faint);
|
color: var(--admin-text-faint);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-card-icon {
|
.block-card-icon {
|
||||||
display: flex;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--admin-text-muted);
|
color: var(--admin-text-muted);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-card-info {
|
.block-card-info {
|
||||||
@ -2072,20 +2102,28 @@
|
|||||||
|
|
||||||
.block-card-name {
|
.block-card-name {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.875rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-card-preview {
|
.block-card-preview {
|
||||||
display: block;
|
display: none;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--admin-text-faint);
|
color: var(--admin-text-faint);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-card-controls {
|
.block-card-controls {
|
||||||
@ -2233,6 +2271,11 @@
|
|||||||
gap: 0.25rem 0.5rem;
|
gap: 0.25rem 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
/* Desktop: single row with truncating text */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-card-expanded {
|
.block-card-expanded {
|
||||||
@ -2334,115 +2377,275 @@
|
|||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Live editor layout (sidebar on shop pages) ── */
|
/* ══════════════════════════════════════════════════════════════════════════
|
||||||
|
Editor sheet (unified bottom/right sheet for page editing)
|
||||||
|
══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
.page-editor-live {
|
.editor-sheet {
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-editor-sidebar {
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
z-index: 1000;
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 360px;
|
|
||||||
background: var(--t-surface-base);
|
background: var(--t-surface-base);
|
||||||
border-right: 1px solid var(--t-border-default);
|
box-shadow: var(--t-shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15));
|
||||||
overflow-y: auto;
|
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
z-index: 40;
|
display: flex;
|
||||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
|
flex-direction: column;
|
||||||
padding: 1rem;
|
}
|
||||||
transition: transform 0.25s ease;
|
|
||||||
|
|
||||||
|
@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);
|
transform: translateX(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hidden sidebar — slides off-screen */
|
/* ── Edit button in collapsed state ── */
|
||||||
[data-sidebar-open="false"] .page-editor-sidebar {
|
.editor-sheet-edit-btn {
|
||||||
transform: translateX(-100%);
|
display: flex;
|
||||||
box-shadow: none;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--t-border-default);
|
||||||
|
flex-shrink: 0;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-editor-sidebar-title {
|
.editor-sheet-header-left {
|
||||||
font-size: 1rem;
|
display: flex;
|
||||||
font-weight: 600;
|
align-items: center;
|
||||||
flex: 1;
|
gap: 0.75rem;
|
||||||
min-width: 0;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-editor-sidebar-dirty {
|
/* ── Sheet content ── */
|
||||||
margin-bottom: 0.5rem;
|
.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 */
|
/* ── Hide content when collapsed ── */
|
||||||
.page-editor-sidebar .block-picker-overlay {
|
.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;
|
position: static;
|
||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-editor-sidebar .block-picker {
|
.editor-sheet .block-picker {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-editor-sidebar .block-picker-grid {
|
.editor-sheet .block-picker-grid {
|
||||||
max-height: 45dvh;
|
max-height: 45dvh;
|
||||||
overflow-y: auto;
|
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)
|
Image field (block editor)
|
||||||
═══════════════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════════════ */
|
||||||
|
|||||||
@ -1089,6 +1089,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-icon-btn {
|
.header-icon-btn {
|
||||||
@ -1145,66 +1146,161 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Mobile bottom nav ── */
|
/* ── Hamburger menu button (mobile only) ── */
|
||||||
|
|
||||||
.mobile-bottom-nav {
|
.header-hamburger {
|
||||||
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 {
|
|
||||||
display: flex;
|
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;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.25rem;
|
width: 2.5rem;
|
||||||
padding-block: 0.5rem;
|
height: 2.5rem;
|
||||||
margin-inline: 0.25rem;
|
background: none;
|
||||||
min-height: 56px;
|
border: none;
|
||||||
border-radius: var(--t-radius-card, 0.5rem);
|
cursor: pointer;
|
||||||
font-size: var(--t-text-caption);
|
|
||||||
color: var(--t-text-secondary);
|
color: var(--t-text-secondary);
|
||||||
text-decoration: none;
|
border-radius: var(--t-radius-button);
|
||||||
font-weight: 500;
|
margin-right: 0.5rem;
|
||||||
background-color: transparent;
|
flex-shrink: 0;
|
||||||
|
|
||||||
& svg {
|
&:hover {
|
||||||
width: 1.25rem;
|
background: var(--t-surface-sunken);
|
||||||
height: 1.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&[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 {
|
& svg {
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
height: 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 ── */
|
||||||
|
|
||||||
.search-modal {
|
.search-modal {
|
||||||
@ -2356,13 +2452,7 @@
|
|||||||
/* ── Flash messages ── */
|
/* ── Flash messages ── */
|
||||||
|
|
||||||
.shop-flash-group {
|
.shop-flash-group {
|
||||||
position: fixed;
|
/* document flow — pushes content down, no overlay */
|
||||||
top: 1rem;
|
|
||||||
inset-inline-end: 1rem;
|
|
||||||
z-index: 200;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.shop-flash {
|
.shop-flash {
|
||||||
@ -2370,10 +2460,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.75rem 1rem;
|
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);
|
background-color: var(--t-surface-raised, #fff);
|
||||||
color: var(--t-text-primary);
|
color: var(--t-text-primary);
|
||||||
|
|
||||||
@ -2384,11 +2470,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shop-flash--info {
|
.shop-flash--info {
|
||||||
border: 1px solid var(--t-border-default);
|
border-bottom: 1px solid var(--t-border-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shop-flash--error {
|
.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 {
|
.shop-flash-icon {
|
||||||
@ -2405,11 +2491,6 @@
|
|||||||
color: hsl(0 70% 50%);
|
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 */
|
/* Transition classes for JS.hide flash dismiss */
|
||||||
.fade-out { transition: opacity 200ms ease-out; }
|
.fade-out { transition: opacity 200ms ease-out; }
|
||||||
.fade-out-from { opacity: 1; }
|
.fade-out-from { opacity: 1; }
|
||||||
|
|||||||
105
assets/js/app.js
105
assets/js/app.js
@ -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.
|
// Flex-wrap base → horizontal scroll enhancement for collection category pills.
|
||||||
// If the pills wrap past 2 rows on mobile, switches to single-row scroll
|
// If the pills wrap past 2 rows on mobile, switches to single-row scroll
|
||||||
// and scrolls the active pill into view.
|
// 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
|
// DirtyGuard + Ctrl+Z / Ctrl+Shift+Z undo/redo for page editors
|
||||||
const EditorKeyboard = {
|
const EditorKeyboard = {
|
||||||
mounted() {
|
mounted() {
|
||||||
@ -682,7 +779,7 @@ const EditorKeyboard = {
|
|||||||
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||||
const liveSocket = new LiveSocket("/live", Socket, {
|
const liveSocket = new LiveSocket("/live", Socket, {
|
||||||
params: {_csrf_token: csrfToken, screen_width: window.innerWidth},
|
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
|
// 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
|
// connect if there are any LiveViews on the page
|
||||||
liveSocket.connect()
|
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:
|
// expose liveSocket on window for web console debug logs and latency simulation:
|
||||||
// >> liveSocket.enableDebug()
|
// >> liveSocket.enableDebug()
|
||||||
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
|
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
|
||||||
|
|||||||
@ -16,6 +16,20 @@ defmodule Berrypod.Pages.Defaults do
|
|||||||
|> Enum.map(&for_slug/1)
|
|> Enum.map(&for_slug/1)
|
||||||
end
|
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 ─────────────────────────────────────────────────
|
# ── Page titles ─────────────────────────────────────────────────
|
||||||
|
|
||||||
defp title("home"), do: "Home page"
|
defp title("home"), do: "Home page"
|
||||||
|
|||||||
@ -47,7 +47,7 @@ defmodule BerrypodWeb.CoreComponents do
|
|||||||
id={@id}
|
id={@id}
|
||||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||||
role="alert"
|
role="alert"
|
||||||
class="admin-toast"
|
class="admin-banner"
|
||||||
{@rest}
|
{@rest}
|
||||||
>
|
>
|
||||||
<div class={[
|
<div class={[
|
||||||
@ -70,6 +70,39 @@ defmodule BerrypodWeb.CoreComponents do
|
|||||||
"""
|
"""
|
||||||
end
|
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 """
|
@doc """
|
||||||
Renders a button with navigation support.
|
Renders a button with navigation support.
|
||||||
|
|
||||||
|
|||||||
@ -35,13 +35,13 @@ defmodule BerrypodWeb.Layouts do
|
|||||||
|
|
||||||
def app(assigns) do
|
def app(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
<.flash_group flash={@flash} />
|
||||||
|
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<.flash_group flash={@flash} />
|
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -18,9 +18,10 @@
|
|||||||
</.link>
|
</.link>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<.flash_group flash={@flash} />
|
||||||
|
|
||||||
<%!-- page content --%>
|
<%!-- page content --%>
|
||||||
<main class="admin-main">
|
<main class="admin-main">
|
||||||
<.flash_group flash={@flash} />
|
|
||||||
<div class="admin-container">
|
<div class="admin-container">
|
||||||
{@inner_content}
|
{@inner_content}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -128,9 +128,6 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
mode={@mode}
|
mode={@mode}
|
||||||
cart_count={@cart_count}
|
cart_count={@cart_count}
|
||||||
is_admin={@is_admin}
|
is_admin={@is_admin}
|
||||||
editing={@editing}
|
|
||||||
editor_current_path={@editor_current_path}
|
|
||||||
editor_sidebar_open={@editor_sidebar_open}
|
|
||||||
header_nav_items={@header_nav_items}
|
header_nav_items={@header_nav_items}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -167,11 +164,12 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
search_open={@search_open}
|
search_open={@search_open}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<.mobile_bottom_nav
|
<.mobile_nav_drawer
|
||||||
:if={!@error_page}
|
:if={!@error_page}
|
||||||
active_page={@active_page}
|
active_page={@active_page}
|
||||||
mode={@mode}
|
mode={@mode}
|
||||||
items={@header_nav_items}
|
items={@header_nav_items}
|
||||||
|
categories={assigns[:categories] || []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
@ -513,6 +511,111 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
"""
|
"""
|
||||||
end
|
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 """
|
@doc """
|
||||||
Renders the shop footer with newsletter signup and links.
|
Renders the shop footer with newsletter signup and links.
|
||||||
|
|
||||||
@ -662,9 +765,6 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
attr :mode, :atom, default: :live
|
attr :mode, :atom, default: :live
|
||||||
attr :cart_count, :integer, default: 0
|
attr :cart_count, :integer, default: 0
|
||||||
attr :is_admin, :boolean, default: false
|
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: []
|
attr :header_nav_items, :list, default: []
|
||||||
|
|
||||||
def shop_header(assigns) do
|
def shop_header(assigns) do
|
||||||
@ -674,6 +774,27 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
<div style={header_background_style(@theme_settings, @header_image)} />
|
<div style={header_background_style(@theme_settings, @header_image)} />
|
||||||
<% end %>
|
<% 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">
|
<div class="shop-logo">
|
||||||
<.logo_content
|
<.logo_content
|
||||||
theme_settings={@theme_settings}
|
theme_settings={@theme_settings}
|
||||||
@ -697,48 +818,14 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="shop-actions">
|
<div class="shop-actions">
|
||||||
<%!-- Pencil icon: enters edit mode, or re-opens sidebar if already editing --%>
|
<%!-- Admin cog: always visible for admins, links to admin dashboard --%>
|
||||||
<.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>
|
|
||||||
<.link
|
<.link
|
||||||
:if={@is_admin}
|
:if={@is_admin}
|
||||||
href="/admin"
|
href="/admin"
|
||||||
class="header-icon-btn"
|
class="header-icon-btn"
|
||||||
aria-label="Admin"
|
aria-label="Admin dashboard"
|
||||||
>
|
>
|
||||||
<svg
|
<.admin_cog_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>
|
|
||||||
</.link>
|
</.link>
|
||||||
<a
|
<a
|
||||||
href="/search"
|
href="/search"
|
||||||
@ -950,4 +1037,197 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
defp open_cart_drawer_js do
|
defp open_cart_drawer_js do
|
||||||
Phoenix.LiveView.JS.push("open_cart_drawer")
|
Phoenix.LiveView.JS.push("open_cart_drawer")
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@ -16,10 +16,10 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import Phoenix.Component, only: [assign: 3]
|
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
|
||||||
alias Berrypod.Pages.{BlockEditor, BlockTypes}
|
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
|
||||||
|
|
||||||
def on_mount(:mount_page_editor, _params, _session, socket) do
|
def on_mount(:mount_page_editor, _params, _session, socket) do
|
||||||
socket =
|
socket =
|
||||||
@ -27,6 +27,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
|> assign(:editing, false)
|
|> assign(:editing, false)
|
||||||
|> assign(:editing_blocks, nil)
|
|> assign(:editing_blocks, nil)
|
||||||
|> assign(:editor_dirty, false)
|
|> assign(:editor_dirty, false)
|
||||||
|
|> assign(:editor_at_defaults, true)
|
||||||
|> assign(:editor_history, [])
|
|> assign(:editor_history, [])
|
||||||
|> assign(:editor_future, [])
|
|> assign(:editor_future, [])
|
||||||
|> assign(:editor_expanded, MapSet.new())
|
|> assign(:editor_expanded, MapSet.new())
|
||||||
@ -36,10 +37,12 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
|> assign(:editor_live_region_message, nil)
|
|> assign(:editor_live_region_message, nil)
|
||||||
|> assign(:editor_current_path, nil)
|
|> assign(:editor_current_path, 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, [])
|
||||||
|> assign(:editor_image_picker_search, "")
|
|> assign(:editor_image_picker_search, "")
|
||||||
|
|> assign(:editor_save_status, :idle)
|
||||||
|> 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)
|
||||||
@ -47,50 +50,48 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
{:cont, socket}
|
{:cont, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
# ── handle_params: detect ?edit=true ─────────────────────────────
|
# ── handle_params: track current path ────────────────────────────
|
||||||
|
|
||||||
defp handle_editor_params(_params, uri, socket) do
|
defp handle_editor_params(_params, uri, socket) do
|
||||||
parsed = URI.parse(uri)
|
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
|
# Store the current path for reference (e.g. the Done button)
|
||||||
socket = assign(socket, :editor_current_path, parsed.path)
|
{:cont, 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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# ── handle_info: deferred init ───────────────────────────────────
|
# ── handle_info ─────────────────────────────────────────────────
|
||||||
|
|
||||||
defp handle_editor_info(:editor_deferred_init, socket) do
|
defp handle_editor_info(:editor_clear_save_status, socket) do
|
||||||
if socket.assigns.editing and is_nil(socket.assigns.editing_blocks) and socket.assigns[:page] do
|
{:halt, assign(socket, :editor_save_status, :idle)}
|
||||||
{:halt, enter_edit_mode(socket)}
|
|
||||||
else
|
|
||||||
{:cont, socket}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_editor_info(_msg, socket), do: {:cont, socket}
|
defp handle_editor_info(_msg, socket), do: {:cont, socket}
|
||||||
|
|
||||||
# ── handle_event: editor_* events ────────────────────────────────
|
# ── 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
|
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)
|
||||||
@ -330,6 +331,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
case socket.assigns.editor_history do
|
case socket.assigns.editor_history do
|
||||||
[prev | rest] ->
|
[prev | rest] ->
|
||||||
future = [socket.assigns.editing_blocks | socket.assigns.editor_future]
|
future = [socket.assigns.editing_blocks | socket.assigns.editor_future]
|
||||||
|
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, prev)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@ -337,6 +339,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
|> assign(:editor_history, rest)
|
|> assign(:editor_history, rest)
|
||||||
|> assign(:editor_future, future)
|
|> assign(:editor_future, future)
|
||||||
|> assign(:editor_dirty, true)
|
|> assign(:editor_dirty, true)
|
||||||
|
|> assign(:editor_at_defaults, at_defaults)
|
||||||
|> assign(:editor_live_region_message, "Undone")
|
|> assign(:editor_live_region_message, "Undone")
|
||||||
|> reload_block_data(prev)
|
|> reload_block_data(prev)
|
||||||
|
|
||||||
@ -351,6 +354,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
case socket.assigns.editor_future do
|
case socket.assigns.editor_future do
|
||||||
[next | rest] ->
|
[next | rest] ->
|
||||||
history = [socket.assigns.editing_blocks | socket.assigns.editor_history]
|
history = [socket.assigns.editing_blocks | socket.assigns.editor_history]
|
||||||
|
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, next)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@ -358,6 +362,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
|> assign(:editor_history, history)
|
|> assign(:editor_history, history)
|
||||||
|> assign(:editor_future, rest)
|
|> assign(:editor_future, rest)
|
||||||
|> assign(:editor_dirty, true)
|
|> assign(:editor_dirty, true)
|
||||||
|
|> assign(:editor_at_defaults, at_defaults)
|
||||||
|> assign(:editor_live_region_message, "Redone")
|
|> assign(:editor_live_region_message, "Redone")
|
||||||
|> reload_block_data(next)
|
|> reload_block_data(next)
|
||||||
|
|
||||||
@ -376,39 +381,32 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
case Pages.save_page(page.slug, %{title: page.title, blocks: blocks}) do
|
case Pages.save_page(page.slug, %{title: page.title, blocks: blocks}) do
|
||||||
{:ok, _saved_page} ->
|
{:ok, _saved_page} ->
|
||||||
updated_page = Pages.get_page(page.slug)
|
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 =
|
||||||
socket
|
socket
|
||||||
|> assign(:page, updated_page)
|
|> assign(:page, updated_page)
|
||||||
|> assign(:editing_blocks, updated_page.blocks)
|
|> assign(:editing_blocks, updated_page.blocks)
|
||||||
|> assign(:editor_dirty, false)
|
|> assign(:editor_dirty, false)
|
||||||
|
|> assign(:editor_at_defaults, at_defaults)
|
||||||
|> assign(:editor_history, [])
|
|> assign(:editor_history, [])
|
||||||
|> assign(:editor_future, [])
|
|> assign(:editor_future, [])
|
||||||
|> put_flash(:info, "Page saved")
|
|> assign(:editor_save_status, :saved)
|
||||||
|
|
||||||
{:halt, socket}
|
{:halt, socket}
|
||||||
|
|
||||||
{:error, _changeset} ->
|
{:error, _changeset} ->
|
||||||
{:halt, put_flash(socket, :error, "Failed to save page")}
|
{:halt, assign(socket, :editor_save_status, :error)}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_editor_action("reset_defaults", _params, socket) do
|
defp handle_editor_action("reset_defaults", _params, socket) do
|
||||||
slug = socket.assigns.page.slug
|
slug = socket.assigns.page.slug
|
||||||
:ok = Pages.reset_page(slug)
|
default_blocks = Berrypod.Pages.Defaults.for_slug(slug).blocks
|
||||||
page = Pages.get_page(slug)
|
|
||||||
|
|
||||||
socket =
|
# Treat reset like any other mutation: push to history, mark dirty
|
||||||
socket
|
{:halt, apply_mutation(socket, default_blocks, "Reset to defaults", :content)}
|
||||||
|> 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}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_editor_action("done", _params, socket) do
|
defp handle_editor_action("done", _params, socket) do
|
||||||
@ -424,11 +422,13 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
defp enter_edit_mode(socket) do
|
defp enter_edit_mode(socket) do
|
||||||
page = socket.assigns.page
|
page = socket.assigns.page
|
||||||
allowed = BlockTypes.allowed_for(page.slug)
|
allowed = BlockTypes.allowed_for(page.slug)
|
||||||
|
at_defaults = Defaults.matches_defaults?(page.slug, page.blocks)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:editing, true)
|
|> assign(:editing, true)
|
||||||
|> assign(:editing_blocks, page.blocks)
|
|> assign(:editing_blocks, page.blocks)
|
||||||
|> assign(:editor_dirty, false)
|
|> assign(:editor_dirty, false)
|
||||||
|
|> assign(:editor_at_defaults, at_defaults)
|
||||||
|> assign(:editor_history, [])
|
|> assign(:editor_history, [])
|
||||||
|> assign(:editor_future, [])
|
|> assign(:editor_future, [])
|
||||||
|> assign(:editor_expanded, MapSet.new())
|
|> assign(:editor_expanded, MapSet.new())
|
||||||
@ -437,10 +437,12 @@ defmodule BerrypodWeb.PageEditorHook do
|
|||||||
|> assign(:editor_allowed_blocks, allowed)
|
|> assign(:editor_allowed_blocks, allowed)
|
||||||
|> 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, :open)
|
||||||
|> 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, [])
|
||||||
|> assign(:editor_image_picker_search, "")
|
|> assign(:editor_image_picker_search, "")
|
||||||
|
|> assign(:editor_save_status, :idle)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp exit_edit_mode(socket) do
|
defp exit_edit_mode(socket) do
|
||||||
@ -456,22 +458,27 @@ 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, [])
|
||||||
|> assign(:editor_image_picker_search, "")
|
|> assign(:editor_image_picker_search, "")
|
||||||
|
|> assign(:editor_save_status, :idle)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp apply_mutation(socket, new_blocks, message, type) do
|
defp apply_mutation(socket, new_blocks, message, type) do
|
||||||
history =
|
history =
|
||||||
[socket.assigns.editing_blocks | socket.assigns.editor_history] |> Enum.take(50)
|
[socket.assigns.editing_blocks | socket.assigns.editor_history] |> Enum.take(50)
|
||||||
|
|
||||||
|
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, new_blocks)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:editing_blocks, new_blocks)
|
|> assign(:editing_blocks, new_blocks)
|
||||||
|> assign(:editor_history, history)
|
|> assign(:editor_history, history)
|
||||||
|> assign(:editor_future, [])
|
|> assign(:editor_future, [])
|
||||||
|> assign(:editor_dirty, true)
|
|> assign(:editor_dirty, true)
|
||||||
|
|> assign(:editor_at_defaults, at_defaults)
|
||||||
|> assign(:editor_live_region_message, message)
|
|> assign(:editor_live_region_message, message)
|
||||||
|
|
||||||
case type do
|
case type do
|
||||||
|
|||||||
@ -32,16 +32,16 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
live page editor), wraps the page in a sidebar + content layout.
|
live page editor), wraps the page in a sidebar + content layout.
|
||||||
"""
|
"""
|
||||||
def render_page(assigns) do
|
def render_page(assigns) do
|
||||||
if assigns[:editing] && assigns[:editing_blocks] do
|
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
|
||||||
render_page_with_editor(assigns)
|
|
||||||
|
if assigns[:is_admin] do
|
||||||
|
render_page_with_rail(assigns)
|
||||||
else
|
else
|
||||||
render_page_normal(assigns)
|
render_page_normal(assigns)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp render_page_normal(assigns) do
|
defp render_page_normal(assigns) do
|
||||||
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
|
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<.shop_layout
|
<.shop_layout
|
||||||
{layout_assigns(assigns)}
|
{layout_assigns(assigns)}
|
||||||
@ -57,22 +57,80 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp render_page_with_editor(assigns) do
|
defp render_page_with_rail(assigns) do
|
||||||
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
|
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div
|
<.shop_layout
|
||||||
id="page-editor-live"
|
{layout_assigns(assigns)}
|
||||||
class="page-editor-live"
|
active_page={@page.slug}
|
||||||
phx-hook="EditorKeyboard"
|
error_page={@page.slug == "error"}
|
||||||
data-dirty={to_string(@editor_dirty)}
|
|
||||||
data-event-prefix="editor_"
|
|
||||||
data-sidebar-open={to_string(@editor_sidebar_open)}
|
|
||||||
>
|
>
|
||||||
<aside class="page-editor-sidebar" aria-label="Page editor">
|
<main id="main-content" class={page_main_class(@page.slug)}>
|
||||||
<div class="page-editor-sidebar-header">
|
<%= if @editing && @editing_blocks do %>
|
||||||
<h2 class="page-editor-sidebar-title">{@page.title}</h2>
|
<div
|
||||||
<div class="page-editor-sidebar-actions">
|
: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
|
<button
|
||||||
phx-click="editor_undo"
|
phx-click="editor_undo"
|
||||||
class={[
|
class={[
|
||||||
@ -95,30 +153,6 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
>
|
>
|
||||||
<.icon name="hero-arrow-uturn-right" class="size-4" />
|
<.icon name="hero-arrow-uturn-right" class="size-4" />
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -127,23 +161,18 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
{if @editor_live_region_message, do: @editor_live_region_message}
|
{if @editor_live_region_message, do: @editor_live_region_message}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Unsaved changes indicator --%>
|
|
||||||
<p :if={@editor_dirty} class="admin-badge admin-badge-warning page-editor-sidebar-dirty">
|
|
||||||
Unsaved changes
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<%!-- Block list --%>
|
<%!-- Block list --%>
|
||||||
<div class="block-list" role="list" aria-label="Page blocks">
|
<div class="block-list" role="list" aria-label="Page blocks">
|
||||||
<.block_card
|
<.block_card
|
||||||
:for={{block, idx} <- Enum.with_index(@editing_blocks)}
|
:for={{block, idx} <- Enum.with_index(@editing_blocks || [])}
|
||||||
block={block}
|
block={block}
|
||||||
idx={idx}
|
idx={idx}
|
||||||
total={length(@editing_blocks)}
|
total={length(@editing_blocks || [])}
|
||||||
expanded={@editor_expanded}
|
expanded={@editor_expanded || MapSet.new()}
|
||||||
event_prefix="editor_"
|
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>
|
<p>No blocks on this page yet.</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<button phx-click="editor_show_picker" class="admin-btn admin-btn-outline block-add-btn">
|
||||||
<.icon name="hero-plus" class="size-4" /> Add block
|
<.icon name="hero-plus" class="size-4" /> Add block
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<%!-- Block picker modal --%>
|
<%!-- Block picker modal --%>
|
||||||
@ -170,33 +207,6 @@ defmodule BerrypodWeb.PageRenderer do
|
|||||||
search={@editor_image_picker_search}
|
search={@editor_image_picker_search}
|
||||||
event_prefix="editor_"
|
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>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|||||||
@ -120,10 +120,18 @@ defmodule BerrypodWeb.Shop.CustomPageTest do
|
|||||||
:ok
|
:ok
|
||||||
end
|
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)
|
conn = log_in_user(conn, user)
|
||||||
{:ok, view, _html} = live(conn, "/editable?edit=true")
|
{:ok, view, _html} = live(conn, "/editable")
|
||||||
assert has_element?(view, ".page-editor-sidebar")
|
|
||||||
|
# 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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -29,97 +29,75 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe "non-admin cannot access edit mode" do
|
describe "non-admin cannot access edit mode" do
|
||||||
test "visiting with ?edit=true shows normal page, no sidebar", %{conn: conn} do
|
test "editor sheet is not shown for non-admins", %{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
|
|
||||||
{:ok, _view, html} = live(conn, "/")
|
{:ok, _view, html} = live(conn, "/")
|
||||||
|
|
||||||
|
refute html =~ "editor-sheet"
|
||||||
refute html =~ "Edit page"
|
refute html =~ "Edit page"
|
||||||
end
|
end
|
||||||
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
|
describe "entering and exiting edit mode" do
|
||||||
setup %{conn: conn, user: user} do
|
setup %{conn: conn, user: user} do
|
||||||
%{conn: log_in_user(conn, user)}
|
%{conn: log_in_user(conn, user)}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "admin enters edit mode with ?edit=true", %{conn: conn} do
|
test "clicking edit button enters edit mode", %{conn: conn} do
|
||||||
{:ok, view, _html} = live(conn, "/?edit=true")
|
{:ok, view, _html} = live(conn, "/")
|
||||||
|
|
||||||
assert has_element?(view, ".page-editor-sidebar")
|
# Sheet starts collapsed
|
||||||
assert has_element?(view, ".page-editor-content")
|
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")
|
assert has_element?(view, ".block-card")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sidebar shows the page title", %{conn: conn} do
|
test "sheet shows the page title when editing", %{conn: conn} do
|
||||||
{:ok, view, _html} = live(conn, "/?edit=true")
|
{: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
|
end
|
||||||
|
|
||||||
test "done button exits edit mode", %{conn: conn} do
|
test "sheet state changes when entering edit mode and collapsing", %{conn: conn} do
|
||||||
{:ok, view, _html} = live(conn, "/?edit=true")
|
{:ok, view, _html} = live(conn, "/")
|
||||||
|
|
||||||
# editor_done uses push_navigate, which causes a redirect
|
# Starts collapsed
|
||||||
{:error, {:live_redirect, %{to: "/"}}} =
|
assert has_element?(view, ".editor-sheet[data-state='collapsed']")
|
||||||
view |> element("button[phx-click='editor_done']") |> render_click()
|
|
||||||
end
|
|
||||||
|
|
||||||
test "toggle sidebar hides and shows via header pencil", %{conn: conn} do
|
# Enter edit mode - expands to open
|
||||||
{:ok, view, _html} = live(conn, "/?edit=true")
|
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
|
# Collapse sheet (still in edit mode, just previewing)
|
||||||
assert has_element?(view, "[data-sidebar-open='true']")
|
render_click(view, "editor_set_sheet_state", %{"state" => "collapsed"})
|
||||||
|
|
||||||
refute has_element?(
|
assert has_element?(view, ".editor-sheet[data-state='collapsed']")
|
||||||
view,
|
# Still in edit mode
|
||||||
"button[phx-click='editor_toggle_sidebar'][aria-label='Show editor sidebar']"
|
assert has_element?(view, ".editor-sheet[data-editing='true']")
|
||||||
)
|
|
||||||
|
|
||||||
# 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")
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -129,7 +107,10 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "move block down reorders the list", %{conn: conn} do
|
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
|
# Home page default: hero is first block
|
||||||
first_card = view |> element(".block-card:first-child")
|
first_card = view |> element(".block-card:first-child")
|
||||||
@ -150,9 +131,12 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "dirty indicator appears after changes", %{conn: conn} do
|
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
|
# Move a block to trigger dirty state
|
||||||
blocks = Pages.get_page("home").blocks
|
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}']")
|
|> element("button[phx-click='editor_move_up'][phx-value-id='#{second_id}']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
assert has_element?(view, ".admin-badge-warning", "Unsaved changes")
|
assert has_element?(view, ".editor-sheet-dirty")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -172,7 +156,10 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "save persists block changes", %{conn: conn} do
|
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
|
# Move a block to make changes
|
||||||
blocks = Pages.get_page("home").blocks
|
blocks = Pages.get_page("home").blocks
|
||||||
@ -186,30 +173,43 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
|||||||
# Save
|
# Save
|
||||||
view |> element("button[phx-click='editor_save']") |> render_click()
|
view |> element("button[phx-click='editor_save']") |> render_click()
|
||||||
|
|
||||||
assert has_element?(view, "#shop-flash-info", "Page saved")
|
|
||||||
|
|
||||||
# Verify persistence
|
# Verify persistence
|
||||||
updated = Pages.get_page("home")
|
updated = Pages.get_page("home")
|
||||||
refute List.first(updated.blocks)["type"] == original_first_type
|
refute List.first(updated.blocks)["type"] == original_first_type
|
||||||
end
|
end
|
||||||
|
|
||||||
test "reset restores default blocks", %{conn: conn} do
|
test "reset restores default blocks and is undoable", %{conn: conn} do
|
||||||
# First, save a modified page
|
# First, save a modified page (reverse block order)
|
||||||
original = Pages.get_page("home")
|
original = Pages.get_page("home")
|
||||||
reordered = Enum.reverse(original.blocks)
|
reordered = Enum.reverse(original.blocks)
|
||||||
Pages.save_page("home", %{title: original.title, blocks: reordered})
|
Pages.save_page("home", %{title: original.title, blocks: reordered})
|
||||||
PageCache.invalidate_all()
|
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
|
# Reset
|
||||||
view |> element("button[phx-click='editor_reset_defaults']") |> render_click()
|
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 should be undoable
|
||||||
reset_page = Pages.get_page("home")
|
refute has_element?(view, "button[phx-click='editor_undo'][disabled]")
|
||||||
assert List.first(reset_page.blocks)["type"] == List.first(original.blocks)["type"]
|
|
||||||
|
# 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
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -219,7 +219,10 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "undo reverts the last change", %{conn: conn} do
|
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
|
blocks = Pages.get_page("home").blocks
|
||||||
second_id = Enum.at(blocks, 1)["id"]
|
second_id = Enum.at(blocks, 1)["id"]
|
||||||
@ -241,7 +244,10 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "redo restores an undone change", %{conn: conn} do
|
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
|
blocks = Pages.get_page("home").blocks
|
||||||
second_id = Enum.at(blocks, 1)["id"]
|
second_id = Enum.at(blocks, 1)["id"]
|
||||||
@ -259,7 +265,10 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "history clears on save", %{conn: conn} do
|
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
|
blocks = Pages.get_page("home").blocks
|
||||||
second_id = Enum.at(blocks, 1)["id"]
|
second_id = Enum.at(blocks, 1)["id"]
|
||||||
@ -275,7 +284,10 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "undo/redo buttons reflect stack state", %{conn: conn} do
|
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
|
# Initially both disabled
|
||||||
assert has_element?(view, "button[phx-click='editor_undo'][disabled]")
|
assert has_element?(view, "button[phx-click='editor_undo'][disabled]")
|
||||||
@ -295,16 +307,22 @@ defmodule BerrypodWeb.PageEditorHookTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "content pages (deferred init)" do
|
describe "content pages" do
|
||||||
setup %{conn: conn, user: user} do
|
setup %{conn: conn, user: user} do
|
||||||
%{conn: log_in_user(conn, user)}
|
%{conn: log_in_user(conn, user)}
|
||||||
end
|
end
|
||||||
|
|
||||||
test "editing works on about page via deferred init", %{conn: conn} do
|
test "editing works on about page", %{conn: conn} do
|
||||||
{:ok, view, _html} = live(conn, "/about?edit=true")
|
{:ok, view, _html} = live(conn, "/about")
|
||||||
|
|
||||||
assert has_element?(view, ".page-editor-sidebar")
|
# Editor sheet visible for admin
|
||||||
assert has_element?(view, ".page-editor-sidebar-title", "About")
|
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")
|
assert has_element?(view, ".block-card")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user