added on local at 2026-07-01 13:48:07
| 1 | <!-- ===================================================================== | |
| 2 | Page editor — in-place WYSIWYG. | |
| 3 | Left pane: control panel (metadata, formatting toolbar, save). | |
| 4 | Right pane: live storefront iframe where the user clicks directly | |
| 5 | in the body to edit. Toolbar commands postMessage into the iframe | |
| 6 | and the iframe reports body changes back. No third-party libs. | |
| 7 | ===================================================================== --> | |
| 8 | ||
| 9 | <style> | |
| 10 | /* ---- Full-bleed: escape the wrapper's max-width + padding so the | |
| 11 | editor uses the entire viewport area (next to the sidebar). | |
| 12 | :has() is supported by every modern browser as of 2024. ---- */ | |
| 13 | .app-content:has(.pe-shell) { | |
| 14 | max-width: none; | |
| 15 | padding: 0; | |
| 16 | } | |
| 17 | ||
| 18 | /* ---- Page-editor shell ---- */ | |
| 19 | .pe-shell { | |
| 20 | display: flex; flex-direction: column; gap: 10px; | |
| 21 | padding: 12px 14px 0; | |
| 22 | min-height: 100vh; | |
| 23 | box-sizing: border-box; | |
| 24 | } | |
| 25 | .pe-topbar { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 10px 14px; background: var(--col-surface-1); border: 1px solid var(--col-border); border-radius: 10px; } | |
| 26 | .pe-topbar .pe-back { color: var(--col-text-2); text-decoration: none; font-size: 13px; } | |
| 27 | .pe-topbar .pe-back:hover { color: var(--col-accent-bright); } | |
| 28 | .pe-topbar .pe-title { font-family: var(--font-display); font-size: 17px; font-weight: 700; color: var(--col-text); } | |
| 29 | .pe-topbar .pe-meta { font-size: 11px; color: var(--col-text-3); letter-spacing: 1px; text-transform: uppercase; font-family: var(--font-mono, monospace); } | |
| 30 | .pe-topbar .pe-spacer { flex: 1; } | |
| 31 | .pe-topbar .pe-status { background: var(--col-surface-2); color: var(--col-text); border: 1px solid var(--col-border); padding: 7px 12px; border-radius: 6px; font-size: 13px; } | |
| 32 | .pe-topbar .pe-panel-toggle { | |
| 33 | background: var(--col-surface-2); | |
| 34 | border: 1px solid var(--col-border); | |
| 35 | color: var(--col-text); | |
| 36 | padding: 7px 12px; | |
| 37 | border-radius: 6px; | |
| 38 | font-size: 13px; | |
| 39 | cursor: pointer; | |
| 40 | display: inline-flex; align-items: center; gap: 6px; | |
| 41 | } | |
| 42 | .pe-topbar .pe-panel-toggle:hover { background: var(--col-bg-2); border-color: var(--col-accent); } | |
| 43 | .pe-topbar .pe-panel-toggle svg { width: 14px; height: 14px; } | |
| 44 | ||
| 45 | /* ---- Stacked layout: controls row on top, canvas full-width below. | |
| 46 | When collapsed (.pe-controls-closed) the row collapses to 0 | |
| 47 | height so the canvas claims the entire viewport. ---- */ | |
| 48 | .pe-split { display: flex; flex-direction: column; gap: 12px; min-height: calc(100vh - 110px); } | |
| 49 | .pe-shell.pe-controls-closed .pe-controls { max-height: 0; padding-top: 0; padding-bottom: 0; opacity: 0; pointer-events: none; border-width: 0; } | |
| 50 | ||
| 51 | /* ---- Controls: a single horizontal row. | |
| 52 | Each section is a card so the row wraps gracefully at narrow widths. ---- */ | |
| 53 | .pe-controls { | |
| 54 | background: var(--col-surface-1); | |
| 55 | border: 1px solid var(--col-border); | |
| 56 | border-radius: 10px; | |
| 57 | padding: 14px; | |
| 58 | display: grid; | |
| 59 | /* Body-formatting moved to a full-width row above the canvas, so | |
| 60 | this grid no longer needs a wide column for it. auto-fit lets | |
| 61 | the remaining columns (metadata, hero images, hints) breathe at | |
| 62 | the typical desktop width and stack cleanly at narrow widths. */ | |
| 63 | grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); | |
| 64 | gap: 14px; | |
| 65 | align-items: start; | |
| 66 | transition: max-height 0.25s ease, opacity 0.18s ease, padding 0.2s ease; | |
| 67 | overflow: hidden; | |
| 68 | max-height: 600px; | |
| 69 | } | |
| 70 | .pe-controls > div { min-width: 0; } | |
| 71 | @media (max-width: 760px) { .pe-controls { grid-template-columns: 1fr; } } | |
| 72 | .pe-section-label { font-size: 10px; letter-spacing: 2px; text-transform: uppercase; color: var(--col-text-3); font-weight: 700; margin: 0 0 8px; } | |
| 73 | .pe-hint { background: var(--col-bg-2); border: 1px solid var(--col-border); border-radius: 8px; padding: 12px; font-size: 12px; color: var(--col-text-2); line-height: 1.55; } | |
| 74 | .pe-hint strong { color: var(--col-accent-bright); } | |
| 75 | .pe-hint kbd { background: var(--col-surface-2); border: 1px solid var(--col-border); border-radius: 4px; padding: 1px 6px; font-family: var(--font-mono, monospace); font-size: 11px; color: var(--col-text); } | |
| 76 | ||
| 77 | /* ---- Hero image slots ---- */ | |
| 78 | .pe-hero-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; max-width: 280px; } | |
| 79 | .pe-hero-slot { display: flex; flex-direction: column; gap: 5px; } | |
| 80 | .pe-hero-thumb { | |
| 81 | aspect-ratio: 1; | |
| 82 | background: var(--col-surface-2); | |
| 83 | background-size: cover; | |
| 84 | background-position: center; | |
| 85 | border: 1px solid var(--col-border); | |
| 86 | border-radius: 8px; | |
| 87 | display: grid; place-items: center; | |
| 88 | color: var(--col-text-3); | |
| 89 | position: relative; | |
| 90 | overflow: hidden; | |
| 91 | max-height: 80px; | |
| 92 | } | |
| 93 | .pe-hero-placeholder { | |
| 94 | font-size: 28px; | |
| 95 | font-weight: 700; | |
| 96 | font-family: var(--font-mono, monospace); | |
| 97 | opacity: 0.6; | |
| 98 | } | |
| 99 | .pe-hero-replace { padding: 5px 8px; font-size: 11px; } | |
| 100 | .pe-hero-saving { opacity: 0.5; pointer-events: none; } | |
| 101 | ||
| 102 | /* ---- Toolbar ---- */ | |
| 103 | .we-toolbar { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; padding: 8px; background: var(--col-bg-2); border: 1px solid var(--col-border); border-radius: 8px; } | |
| 104 | .we-tool { background: transparent; border: 1px solid transparent; color: var(--col-text-2); padding: 6px 8px; border-radius: 6px; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; transition: background 0.15s, color 0.15s, border-color 0.15s; font-size: 13px; line-height: 1; } | |
| 105 | .we-tool:hover { background: var(--col-surface-2); color: var(--col-text); } | |
| 106 | .we-tool.active { background: var(--col-accent); color: #fff; border-color: var(--col-accent); } | |
| 107 | .we-tool svg { width: 16px; height: 16px; } | |
| 108 | .we-select { background: var(--col-surface-2); border: 1px solid var(--col-border); color: var(--col-text); padding: 6px 10px; border-radius: 6px; font-size: 13px; cursor: pointer; } | |
| 109 | .we-divider { width: 1px; background: var(--col-border); align-self: stretch; margin: 0 4px; min-height: 22px; } | |
| 110 | ||
| 111 | /* ---- Right: live iframe edit pane (grows to fill viewport height) ---- */ | |
| 112 | .pe-canvas { background: var(--col-bg-2); border: 1px solid var(--col-border); border-radius: 10px; overflow: hidden; display: flex; flex-direction: column; min-height: calc(100vh - 110px); } | |
| 113 | /* Body-formatting toolbar row, full-width across the canvas. Sits | |
| 114 | above the "Editing in place" head so the seller's eye lands on | |
| 115 | formatting controls first, then on the page they're shaping. The | |
| 116 | inner .we-toolbar element loses its own border/background here so | |
| 117 | the row reads as a single integrated bar of chrome. */ | |
| 118 | .pe-canvas-toolbar { background: var(--col-bg); border-bottom: 1px solid var(--col-border); padding: 8px 12px; } | |
| 119 | .pe-canvas-toolbar .we-toolbar { background: transparent; border: none; border-radius: 0; padding: 0; gap: 4px; } | |
| 120 | .pe-canvas-head { background: var(--col-bg); border-bottom: 1px solid var(--col-border); padding: 10px 14px; display: flex; align-items: center; gap: 10px; font-family: var(--font-mono, monospace); font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--col-text-3); } | |
| 121 | .pe-canvas-head .pe-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--col-accent); box-shadow: 0 0 8px var(--col-accent-glow); } | |
| 122 | .pe-canvas-head .pe-spacer { flex: 1; } | |
| 123 | .pe-canvas-head a { color: var(--col-text-2); text-decoration: none; font-size: 11px; } | |
| 124 | .pe-canvas-head a:hover { color: var(--col-accent-bright); } | |
| 125 | .pe-canvas-frame { flex: 1; width: 100%; height: 100%; border: none; background: var(--col-bg); display: block; min-height: calc(100vh - 160px); } | |
| 126 | ||
| 127 | /* Upload progress strip floats over the canvas head */ | |
| 128 | .pe-progress { display: none; padding: 8px 14px; background: var(--col-bg-2); border-bottom: 1px solid var(--col-border); font-size: 13px; color: var(--col-text-2); font-family: var(--font-mono, monospace); } | |
| 129 | .pe-progress.show { display: block; } | |
| 130 | .pe-progress.error { color: #f87171; } | |
| 131 | </style> | |
| 132 | ||
| 133 | <form method="POST" action="/page.cgi" id="page-form" class="pe-shell"> | |
| 134 | <input type="hidden" name="action" value="save"> | |
| 135 | <input type="hidden" name="id" value="$page_id"> | |
| 136 | <input type="hidden" name="store" value="$store_id"> | |
| 137 | [if:$is_new]<input type="hidden" name="new" value="1">[/if] | |
| 138 | <input type="hidden" name="body" id="body-input"> | |
| 139 | <input type="hidden" name="status" id="status-input" value="draft"> | |
| 140 | ||
| 141 | <!-- Sticky top bar: nav, title info, status, save --> | |
| 142 | <div class="pe-topbar"> | |
| 143 | <a href="/storefront.cgi" class="pe-back">← Back to storefront</a> | |
| 144 | <button type="button" class="pe-panel-toggle" id="pe-panel-toggle" title="Show/hide editor panel" aria-pressed="true"> | |
| 145 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg> | |
| 146 | <span id="pe-panel-toggle-label">Hide panel</span> | |
| 147 | </button> | |
| 148 | <span class="pe-title">[if:$is_new]New page[/if][if:!$is_new]Edit "$title"[/if]</span> | |
| 149 | <span class="pe-meta">[if:$is_new]Type: $page_type[/if][if:!$is_new]Type: $page_type · ID #$page_id[/if]</span> | |
| 150 | <span class="pe-spacer"></span> | |
| 151 | <select class="pe-status" id="pe-status-select"> | |
| 152 | <option value="draft" [if:$is_draft]selected[/if]>Draft</option> | |
| 153 | <option value="published" [if:$is_published]selected[/if][if:$is_new]selected[/if]>Published</option> | |
| 154 | <option value="archived" [if:$is_archived]selected[/if]>Archived</option> | |
| 155 | </select> | |
| 156 | <button type="submit" class="btn btn-primary"> | |
| 157 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> | |
| 158 | Save changes | |
| 159 | </button> | |
| 160 | </div> | |
| 161 | ||
| 162 | [if:$saved_flash] | |
| 163 | <div class="banner success"> | |
| 164 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg> | |
| 165 | <div class="text-xs">Saved.</div> | |
| 166 | </div> | |
| 167 | [/if] | |
| 168 | ||
| 169 | <div class="pe-split"> | |
| 170 | ||
| 171 | <!-- ===== LEFT: control panel ===== --> | |
| 172 | <aside class="pe-controls"> | |
| 173 | ||
| 174 | <div> | |
| 175 | <div class="form-group" style="margin-bottom:12px"> | |
| 176 | <label class="form-label">Title <span class="req">*</span></label> | |
| 177 | <input class="input" id="peTitle" type="text" name="title" value="$title" required maxlength="200"> | |
| 178 | <div class="form-help">Shown as the page heading and the browser tab title.</div> | |
| 179 | </div> | |
| 180 | <div class="form-group" style="margin-bottom:12px"> | |
| 181 | <label class="form-label">Menu link label <span class="text-xs text-dim">(optional)</span></label> | |
| 182 | <input class="input" id="peMenuLabel" type="text" name="menu_label" value="$menu_label" maxlength="120" placeholder="e.g. Maker"> | |
| 183 | <div class="form-help">Short text that appears in the storefront header nav. Leave empty to use the page title.</div> | |
| 184 | </div> | |
| 185 | <div class="form-group" style="margin-bottom:0"> | |
| 186 | <label class="form-label">URL slug</label> | |
| 187 | <input class="input" id="peSlug" type="text" name="slug" value="$slug" pattern="[a-z0-9_-]*" maxlength="120" placeholder="my_page"> | |
| 188 | <div class="form-help">Lowercase only. Spaces become underscores; other characters are stripped. Leave empty and we'll generate one from the title.</div> | |
| 189 | </div> | |
| 190 | </div> | |
| 191 | <script> | |
| 192 | /* Auto-format the slug field: lowercase, spaces -> underscores, | |
| 193 | strip anything that is not [a-z0-9_-]. While the slug is | |
| 194 | empty we also mirror the title into it (slug-ified) so the | |
| 195 | seller does not have to type it twice. Once they manually | |
| 196 | edit the slug, we stop auto-syncing. */ | |
| 197 | (function () { | |
| 198 | var titleEl = document.getElementById('peTitle'); | |
| 199 | var slugEl = document.getElementById('peSlug'); | |
| 200 | if (!titleEl || !slugEl) return; | |
| 201 | ||
| 202 | function slugify(s) { | |
| 203 | return (s || '') | |
| 204 | .toLowerCase() | |
| 205 | .replace(/\s+/g, '_') | |
| 206 | .replace(/[^a-z0-9_-]/g, '') | |
| 207 | .replace(/^[_-]+|[_-]+\$/g, '') | |
| 208 | .slice(0, 120); | |
| 209 | } | |
| 210 | ||
| 211 | var userTouchedSlug = (slugEl.value || '').length > 0; | |
| 212 | slugEl.addEventListener('input', function () { | |
| 213 | userTouchedSlug = true; | |
| 214 | var pos = slugEl.selectionStart; | |
| 215 | var before = slugEl.value; | |
| 216 | var after = slugify(before); | |
| 217 | if (after !== before) { | |
| 218 | slugEl.value = after; | |
| 219 | try { slugEl.setSelectionRange(pos - (before.length - after.length), pos - (before.length - after.length)); } catch (e) {} | |
| 220 | } | |
| 221 | }); | |
| 222 | titleEl.addEventListener('input', function () { | |
| 223 | if (userTouchedSlug) return; | |
| 224 | slugEl.value = slugify(titleEl.value); | |
| 225 | }); | |
| 226 | })(); | |
| 227 | </script> | |
| 228 | ||
| 229 | <!-- Body formatting toolbar moved out of this controls aside and | |
| 230 | down to a full-width row above the canvas. Lives there now | |
| 231 | because it controls the page CONTENT directly -- placing it | |
| 232 | inline with the canvas (instead of stuck in a sidebar column) | |
| 233 | makes the visual cause-and-effect immediate. --> | |
| 234 | ||
| 235 | [if:$is_home_page] | |
| 236 | <!-- Hero images panel. Only shown when editing the home page. | |
| 237 | The catalog/spotlight layouts use 3 floating cards which can | |
| 238 | hide each other at narrow iframe widths; these slots are the | |
| 239 | reliable access path to replace any of the 3 images. --> | |
| 240 | <div> | |
| 241 | <div class="pe-section-label">Hero images</div> | |
| 242 | <div class="pe-hero-grid"> | |
| 243 | <div class="pe-hero-slot" data-slot="1"> | |
| 244 | <div class="pe-hero-thumb" id="pe-hero-thumb-1" style="[if:$has_hero_image_1]background:url('$hero_image_1') $hero_image_1_pos / cover[/if]"> | |
| 245 | [if:!$has_hero_image_1]<span class="pe-hero-placeholder">1</span>[/if] | |
| 246 | </div> | |
| 247 | <button type="button" class="pe-hero-replace btn btn-secondary btn-sm" data-slot="1">Replace</button> | |
| 248 | </div> | |
| 249 | <div class="pe-hero-slot" data-slot="2"> | |
| 250 | <div class="pe-hero-thumb" id="pe-hero-thumb-2" style="[if:$has_hero_image_2]background:url('$hero_image_2') $hero_image_2_pos / cover[/if]"> | |
| 251 | [if:!$has_hero_image_2]<span class="pe-hero-placeholder">2</span>[/if] | |
| 252 | </div> | |
| 253 | <button type="button" class="pe-hero-replace btn btn-secondary btn-sm" data-slot="2">Replace</button> | |
| 254 | </div> | |
| 255 | <div class="pe-hero-slot" data-slot="3"> | |
| 256 | <div class="pe-hero-thumb" id="pe-hero-thumb-3" style="[if:$has_hero_image_3]background:url('$hero_image_3') $hero_image_3_pos / cover[/if]"> | |
| 257 | [if:!$has_hero_image_3]<span class="pe-hero-placeholder">3</span>[/if] | |
| 258 | </div> | |
| 259 | <button type="button" class="pe-hero-replace btn btn-secondary btn-sm" data-slot="3">Replace</button> | |
| 260 | </div> | |
| 261 | </div> | |
| 262 | <div class="pe-hint" style="margin-top:8px"> | |
| 263 | The 3 floating cards on the right are these slots. Replace any one, then drag inside the card in the preview to re-frame. | |
| 264 | </div> | |
| 265 | </div> | |
| 266 | [/if] | |
| 267 | ||
| 268 | <div class="pe-hint"> | |
| 269 | <strong>Click in the dashed area</strong> on the right to start writing.<br> | |
| 270 | <strong>Drag and drop</strong> images right onto the page — they upload automatically.<br> | |
| 271 | <strong>Paste</strong> screenshots from your clipboard.<br> | |
| 272 | Click any text in the preview to edit it in place. | |
| 273 | </div> | |
| 274 | ||
| 275 | </aside> | |
| 276 | ||
| 277 | <!-- ===== RIGHT: editing canvas (iframe) ===== --> | |
| 278 | <div class="pe-canvas"> | |
| 279 | <!-- Body formatting toolbar lives at the top of the canvas now. | |
| 280 | Full-width, flush against the page, so it visually reads as | |
| 281 | "the tools for the thing right below me." --> | |
| 282 | <div class="pe-canvas-toolbar"> | |
| 283 | <div class="we-toolbar" id="we-toolbar"> | |
| 284 | <select class="we-select" id="we-heading" title="Block style"> | |
| 285 | <option value="P">Paragraph</option> | |
| 286 | <option value="H1">Heading 1</option> | |
| 287 | <option value="H2">Heading 2</option> | |
| 288 | <option value="H3">Heading 3</option> | |
| 289 | </select> | |
| 290 | <span class="we-divider"></span> | |
| 291 | <button type="button" class="we-tool" data-cmd="bold" title="Bold (Ctrl+B)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h7a4 4 0 0 1 0 8H6z"/><path d="M6 12h8a4 4 0 0 1 0 8H6z"/></svg></button> | |
| 292 | <button type="button" class="we-tool" data-cmd="italic" title="Italic (Ctrl+I)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg></button> | |
| 293 | <button type="button" class="we-tool" data-cmd="underline" title="Underline (Ctrl+U)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 3v7a6 6 0 0 0 12 0V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg></button> | |
| 294 | <button type="button" class="we-tool" data-cmd="strikeThrough" title="Strikethrough"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="12" x2="20" y2="12"/><path d="M16 6c-2.5-2-9-2-9 2 0 2 2 3 5 3"/><path d="M8 18c2.5 2 9 2 9-2 0-2-2-3-5-3"/></svg></button> | |
| 295 | <span class="we-divider"></span> | |
| 296 | <button type="button" class="we-tool" data-cmd="insertUnorderedList" title="Bullet list"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4.5" cy="6" r="1.2" fill="currentColor"/><circle cx="4.5" cy="12" r="1.2" fill="currentColor"/><circle cx="4.5" cy="18" r="1.2" fill="currentColor"/></svg></button> | |
| 297 | <button type="button" class="we-tool" data-cmd="insertOrderedList" title="Numbered list"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 4h1v4"/><path d="M4 8h2"/><path d="M4 14c0-.6.4-1 1-1s1 .4 1 1c0 1-2 1-2 2h2"/><path d="M4 18h1.5c.5 0 1 .2 1 .8 0 .6-.4.7-.8.7H4"/></svg></button> | |
| 298 | <button type="button" class="we-tool" data-cmd="blockquote" title="Quote"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.8-2-2-2H4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h.5c0 1.5-.5 2-2 2"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.8-2-2-2h-4c-1.25 0-2 .75-2 2v6c0 1.25.75 2 2 2h.5c0 1.5-.5 2-2 2"/></svg></button> | |
| 299 | <button type="button" class="we-tool" data-cmd="codeblock" title="Code block"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></button> | |
| 300 | <span class="we-divider"></span> | |
| 301 | <button type="button" class="we-tool" data-cmd="link" title="Add link"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button> | |
| 302 | <button type="button" class="we-tool" data-cmd="image" title="Insert image"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg></button> | |
| 303 | <span class="we-divider"></span> | |
| 304 | <button type="button" class="we-tool" data-cmd="removeFormat" title="Clear formatting"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><line x1="5" y1="20" x2="11" y2="20"/><line x1="13" y1="4" x2="8" y2="20"/><line x1="18" y1="14" x2="22" y2="18"/><line x1="22" y1="14" x2="18" y2="18"/></svg></button> | |
| 305 | </div> | |
| 306 | </div> | |
| 307 | <div class="pe-canvas-head"> | |
| 308 | <span class="pe-dot"></span> | |
| 309 | <span>Editing in place</span> | |
| 310 | <span class="pe-spacer"></span> | |
| 311 | [if:!$is_new]<a href="/store.cgi?id=$store_id&page=$slug" target="_blank">View as buyer →</a>[/if] | |
| 312 | </div> | |
| 313 | <div class="pe-progress" id="pe-progress"></div> | |
| 314 | <iframe class="pe-canvas-frame" id="pe-frame" src="/store.cgi?id=$store_id&page=$slug&editor_page_id=$page_id&editor_live=1" title="Storefront edit canvas"></iframe> | |
| 315 | </div> | |
| 316 | </div> | |
| 317 | </form> | |
| 318 | ||
| 319 | <script> | |
| 320 | /* | |
| 321 | * Page editor controller. | |
| 322 | * | |
| 323 | * The actual editing happens in the iframe (which contains the user's | |
| 324 | * storefront layout). Buttons in this control panel post commands to | |
| 325 | * the iframe; the iframe reports body changes back. The hidden form | |
| 326 | * input mirrors the latest body so a normal POST submits everything. | |
| 327 | */ | |
| 328 | (function() { | |
| 329 | var INITIAL_BODY = $body_json; | |
| 330 | ||
| 331 | var iframe = document.getElementById('pe-frame'); | |
| 332 | var bodyEl = document.getElementById('body-input'); | |
| 333 | var form = document.getElementById('page-form'); | |
| 334 | ||
| 335 | // Controls panel toggle. Preference persists in localStorage so the | |
| 336 | // last-used layout is restored on reload. The class on .pe-shell | |
| 337 | // (set/removed by this button) drives the CSS transition. | |
| 338 | (function () { | |
| 339 | var btn = document.getElementById('pe-panel-toggle'); | |
| 340 | var label = document.getElementById('pe-panel-toggle-label'); | |
| 341 | var shell = form; | |
| 342 | if (!btn || !shell) return; | |
| 343 | var KEY = 'webstls_pe_panel_closed'; | |
| 344 | function apply(closed) { | |
| 345 | shell.classList.toggle('pe-controls-closed', closed); | |
| 346 | btn.setAttribute('aria-pressed', closed ? 'false' : 'true'); | |
| 347 | if (label) label.textContent = closed ? 'Show panel' : 'Hide panel'; | |
| 348 | } | |
| 349 | apply(localStorage.getItem(KEY) === '1'); | |
| 350 | btn.addEventListener('click', function () { | |
| 351 | var nowClosed = !shell.classList.contains('pe-controls-closed'); | |
| 352 | apply(nowClosed); | |
| 353 | try { localStorage.setItem(KEY, nowClosed ? '1' : '0'); } catch (e) {} | |
| 354 | }); | |
| 355 | })(); | |
| 356 | var heading = document.getElementById('we-heading'); | |
| 357 | var statusSel = document.getElementById('pe-status-select'); | |
| 358 | var statusInp = document.getElementById('status-input'); | |
| 359 | var progress = document.getElementById('pe-progress'); | |
| 360 | ||
| 361 | // Body input starts with the server-rendered body so a save without | |
| 362 | // any edits still posts the right value. | |
| 363 | bodyEl.value = INITIAL_BODY || ''; | |
| 364 | ||
| 365 | // Status select -> hidden input | |
| 366 | function syncStatus() { statusInp.value = statusSel.value; } | |
| 367 | statusSel.addEventListener('change', syncStatus); | |
| 368 | syncStatus(); | |
| 369 | form.addEventListener('submit', syncStatus); | |
| 370 | ||
| 371 | // ==================================================================== | |
| 372 | // Cross-frame messaging | |
| 373 | // ==================================================================== | |
| 374 | function send(cmd) { | |
| 375 | if (!iframe || !iframe.contentWindow) return; | |
| 376 | try { iframe.contentWindow.postMessage(cmd, window.location.origin); } catch (e) {} | |
| 377 | } | |
| 378 | ||
| 379 | window.addEventListener('message', function(e) { | |
| 380 | if (!e || !e.data || typeof e.data !== 'object') return; | |
| 381 | var d = e.data; | |
| 382 | ||
| 383 | if (d.type === 'we-preview-ready') { | |
| 384 | // Iframe is loaded. Push the saved body so the canvas matches. | |
| 385 | if (INITIAL_BODY) send({ type: 'we-set-body', html: INITIAL_BODY }); | |
| 386 | return; | |
| 387 | } | |
| 388 | if (d.type === 'we-body-changed') { | |
| 389 | bodyEl.value = d.html || ''; | |
| 390 | return; | |
| 391 | } | |
| 392 | if (d.type === 'we-state') { | |
| 393 | // Mirror Bold/Italic/etc. button highlights from the iframe's | |
| 394 | // current selection state. | |
| 395 | ['bold','italic','underline','strikeThrough'].forEach(function(c){ | |
| 396 | var btn = document.querySelector('.we-tool[data-cmd="' + c + '"]'); | |
| 397 | if (btn) btn.classList.toggle('active', !!(d.states && d.states[c])); | |
| 398 | }); | |
| 399 | return; | |
| 400 | } | |
| 401 | if (d.type === 'we-upload-progress') { | |
| 402 | showProgress(d.message || 'Uploading...', false); | |
| 403 | return; | |
| 404 | } | |
| 405 | if (d.type === 'we-upload-done') { | |
| 406 | if (d.success) hideProgress(); | |
| 407 | else { showProgress('Upload failed: ' + (d.error || 'unknown'), true); setTimeout(hideProgress, 4500); } | |
| 408 | return; | |
| 409 | } | |
| 410 | }); | |
| 411 | ||
| 412 | // ==================================================================== | |
| 413 | // Toolbar -> iframe commands | |
| 414 | // ==================================================================== | |
| 415 | document.querySelectorAll('.we-tool[data-cmd]').forEach(function(btn) { | |
| 416 | // Crucial: prevent mousedown from stealing focus from the iframe, | |
| 417 | // otherwise the iframe's selection collapses and the command | |
| 418 | // applies to nothing. | |
| 419 | btn.addEventListener('mousedown', function(e) { e.preventDefault(); }); | |
| 420 | btn.addEventListener('click', function(e) { | |
| 421 | e.preventDefault(); | |
| 422 | runCommand(btn.dataset.cmd); | |
| 423 | }); | |
| 424 | }); | |
| 425 | ||
| 426 | heading.addEventListener('change', function() { | |
| 427 | send({ type: 'we-exec-command', command: 'formatBlock', value: heading.value }); | |
| 428 | heading.blur(); | |
| 429 | }); | |
| 430 | ||
| 431 | function runCommand(cmd) { | |
| 432 | if (cmd === 'link') { | |
| 433 | appPrompt({ | |
| 434 | title: 'Insert link', | |
| 435 | message: 'Enter the URL the selected text should link to.', | |
| 436 | placeholder: 'https://', | |
| 437 | default_value: 'https://', | |
| 438 | input_type: 'url', | |
| 439 | label: 'Insert link', | |
| 440 | required: true, | |
| 441 | validate: function (v) { | |
| 442 | if (v === 'https://') return 'Enter a real URL.'; | |
| 443 | if (!/^https?:\\/\\//i.test(v)) return 'Must start with http:// or https://'; | |
| 444 | return ''; | |
| 445 | } | |
| 446 | }).then(function (url) { | |
| 447 | if (url) send({ type: 'we-exec-command', command: 'createLink', value: url }); | |
| 448 | }); | |
| 449 | return; | |
| 450 | } | |
| 451 | if (cmd === 'image') { pickImage(); return; } | |
| 452 | if (cmd === 'blockquote') { send({ type: 'we-exec-command', command: 'formatBlock', value: 'BLOCKQUOTE' }); return; } | |
| 453 | if (cmd === 'codeblock') { send({ type: 'we-exec-command', command: 'formatBlock', value: 'PRE' }); return; } | |
| 454 | send({ type: 'we-exec-command', command: cmd }); | |
| 455 | } | |
| 456 | ||
| 457 | // ==================================================================== | |
| 458 | // Image upload (toolbar button picker) | |
| 459 | // Drag/drop and paste are handled INSIDE the iframe -- the iframe | |
| 460 | // uploads itself and reports progress back here so the strip shows. | |
| 461 | // ==================================================================== | |
| 462 | function pickImage() { | |
| 463 | var input = document.createElement('input'); | |
| 464 | input.type = 'file'; | |
| 465 | input.accept = 'image/*'; | |
| 466 | input.onchange = function() { | |
| 467 | if (!input.files || !input.files[0]) return; | |
| 468 | uploadFromParent(input.files[0]); | |
| 469 | }; | |
| 470 | input.click(); | |
| 471 | } | |
| 472 | ||
| 473 | function uploadFromParent(file) { | |
| 474 | showProgress('Uploading ' + (file.name || 'image') + '...', false); | |
| 475 | var fd = new FormData(); | |
| 476 | fd.append('image', file); | |
| 477 | fetch('/upload_image.cgi', { method: 'POST', body: fd, credentials: 'same-origin' }) | |
| 478 | .then(function(r){ return r.json(); }) | |
| 479 | .then(function(data) { | |
| 480 | if (data && data.success && data.url) { | |
| 481 | hideProgress(); | |
| 482 | var safeAlt = (file.name || '').replace(/&/g,'&').replace(/"/g,'"'); | |
| 483 | send({ type: 'we-insert-html', html: '<img src="' + data.url + '" alt="' + safeAlt + '"><br>' }); | |
| 484 | } else { | |
| 485 | showProgress('Upload failed: ' + ((data && data.error) || 'unknown'), true); | |
| 486 | setTimeout(hideProgress, 4500); | |
| 487 | } | |
| 488 | }) | |
| 489 | .catch(function(err){ | |
| 490 | showProgress('Upload failed: ' + err.message, true); | |
| 491 | setTimeout(hideProgress, 4500); | |
| 492 | }); | |
| 493 | } | |
| 494 | ||
| 495 | function showProgress(msg, isError) { progress.textContent = msg; progress.classList.toggle('error', !!isError); progress.classList.add('show'); } | |
| 496 | function hideProgress() { progress.classList.remove('show', 'error'); } | |
| 497 | ||
| 498 | // ----- Hero image slot handlers (left-panel Replace buttons) ----- | |
| 499 | // Each Replace button opens a file picker, uploads the file, then | |
| 500 | // saves the new URL to the matching storefronts.hero_image_N column. | |
| 501 | // We update the thumbnail + reload the iframe so the seller sees the | |
| 502 | // new image immediately in the live preview. | |
| 503 | var STORE_ID = "$store_id"; | |
| 504 | document.querySelectorAll('.pe-hero-replace').forEach(function (btn) { | |
| 505 | btn.addEventListener('click', function () { | |
| 506 | var slot = btn.getAttribute('data-slot'); | |
| 507 | var input = document.createElement('input'); | |
| 508 | input.type = 'file'; | |
| 509 | input.accept = 'image/*'; | |
| 510 | input.onchange = function () { | |
| 511 | if (!input.files || !input.files[0]) return; | |
| 512 | uploadHero(slot, input.files[0]); | |
| 513 | }; | |
| 514 | input.click(); | |
| 515 | }); | |
| 516 | }); | |
| 517 | ||
| 518 | function uploadHero(slot, file) { | |
| 519 | var slotEl = document.querySelector('.pe-hero-slot[data-slot="' + slot + '"]'); | |
| 520 | if (slotEl) slotEl.classList.add('pe-hero-saving'); | |
| 521 | showProgress('Uploading hero ' + slot + '...', false); | |
| 522 | ||
| 523 | var fd = new FormData(); | |
| 524 | fd.append('image', file); | |
| 525 | fetch('/upload_image.cgi', { method: 'POST', body: fd, credentials: 'same-origin' }) | |
| 526 | .then(function (r) { return r.json(); }) | |
| 527 | .then(function (data) { | |
| 528 | if (!data || !data.success || !data.url) { | |
| 529 | throw new Error((data && data.error) || 'upload failed'); | |
| 530 | } | |
| 531 | // Now save the URL to the storefront row. | |
| 532 | var fd2 = new FormData(); | |
| 533 | fd2.append('storefront_id', STORE_ID); | |
| 534 | fd2.append('field', 'hero_image_' + slot); | |
| 535 | fd2.append('value', data.url); | |
| 536 | return fetch('/update_storefront_field.cgi', { | |
| 537 | method: 'POST', body: fd2, credentials: 'same-origin' | |
| 538 | }).then(function (r) { return r.json(); }).then(function (saved) { | |
| 539 | if (!saved || !saved.success) { | |
| 540 | throw new Error((saved && saved.error) || 'save failed'); | |
| 541 | } | |
| 542 | // Update thumbnail in place. | |
| 543 | var thumb = document.getElementById('pe-hero-thumb-' + slot); | |
| 544 | if (thumb) { | |
| 545 | thumb.style.background = "url('" + data.url + "') center / cover"; | |
| 546 | thumb.innerHTML = ''; | |
| 547 | } | |
| 548 | // Reload the iframe so the live preview shows the new image. | |
| 549 | if (iframe) iframe.src = iframe.src; | |
| 550 | showProgress('Hero ' + slot + ' updated.', false); | |
| 551 | setTimeout(hideProgress, 1500); | |
| 552 | }); | |
| 553 | }) | |
| 554 | .catch(function (err) { | |
| 555 | showProgress('Replace failed: ' + err.message, true); | |
| 556 | setTimeout(hideProgress, 4500); | |
| 557 | }) | |
| 558 | .finally(function () { | |
| 559 | if (slotEl) slotEl.classList.remove('pe-hero-saving'); | |
| 560 | }); | |
| 561 | } | |
| 562 | })(); | |
| 563 | </script> |