diff --git a/PROGRESS.md b/PROGRESS.md index 8088708..31d9bab 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -147,7 +147,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m | ~~98~~ | ~~Stage 2: routing + LiveView — `Shop.CustomPage`, catch-all route, 404 handling~~ | 97 | 1h | done | | ~~99~~ | ~~Stage 3: admin CRUD — create/edit/delete pages, page settings, admin index~~ | 98 | 2.5h | done | | ~~100~~ | ~~Stage 4: navigation management — data-driven nav, settings storage, admin editor~~ | 99 | 3h | done | -| 101 | Stage 5: SEO + redirects — sitemap, auto-redirect on slug change, draft/published | 100 | 1h | planned | +| ~~101~~ | ~~Stage 5: SEO + redirects — sitemap, auto-redirect on slug change, draft/published~~ | 100 | 1h | done | | 102 | Stage 6: polish — page templates, new block types, bulk ops | 101 | 3-4h | deferred | | | **Platform site** | | | | | 73 | Platform/marketing site — brochure, pricing, sign-up | — | TBD | planned | @@ -487,7 +487,7 @@ Admin media library at `/admin/media` with image grid, type/search/orphan filter See: [docs/plans/media-library.md](docs/plans/media-library.md) for full plan ### Page Editor -**Status:** In progress — Stage 8 of 9 complete, 1455 tests +**Status:** Complete — all 9 stages done, 1485 tests Database-driven page builder. Every page is a flat list of blocks stored as JSON — add, remove, reorder, and edit blocks on any page. One generic renderer for all pages (no page-specific render functions). Portable blocks (hero, featured_products, image_text, etc.) work on any page. Page-specific blocks (product_hero, cart_items, etc.) are restricted to their native page. Block data loaders dynamically load data based on which blocks are on the page. ETS-cached page definitions. Mobile-first admin editor with live preview, undo/redo, accessible reordering (no drag-and-drop), inline settings forms, and "reset to defaults". CSS-driven page layout (not renderer-driven). @@ -501,7 +501,7 @@ Database-driven page builder. Every page is a flat list of blocks stored as JSON 7. ~~Admin editor — inline block settings editing~~ ✅ (`3f97742`) 7b. ~~SettingsField struct + repeater field type for info_card items~~ ✅ (`6fbd654`) 8. ~~Live page editor sidebar — collapsible sidebar on shop pages, backdrop dismiss, portable content block~~ ✅ (`a039c8d`) -9. **Next →** Undo/redo + polish — history stacks, keyboard shortcuts, animations +9. ~~Undo/redo + polish — history stacks, keyboard shortcuts, settings animations~~ ✅ **Key files created:** - `lib/berrypod/pages.ex` — context (CRUD + cache + load_block_data) diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index e2a6b24..519b084 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -1475,6 +1475,12 @@ padding: 0.75rem 0.75rem 0.25rem; padding-left: 2.75rem; border-top: 1px solid var(--t-border-default); + animation: blockSettingsFadeIn 0.15s ease; +} + +@keyframes blockSettingsFadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } } .block-settings-fields { diff --git a/assets/js/app.js b/assets/js/app.js index 1850ad6..4f2415b 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -651,10 +651,41 @@ const DirtyGuard = { } } +// DirtyGuard + Ctrl+Z / Ctrl+Shift+Z undo/redo for page editors +const EditorKeyboard = { + mounted() { + this._beforeUnload = (e) => { + if (this.el.dataset.dirty === "true") { + e.preventDefault() + e.returnValue = "" + } + } + window.addEventListener("beforeunload", this._beforeUnload) + + const prefix = this.el.dataset.eventPrefix || "" + this._keydown = (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === "z") { + e.preventDefault() + if (e.shiftKey) { + this.pushEvent(prefix + "redo") + } else { + this.pushEvent(prefix + "undo") + } + } + } + document.addEventListener("keydown", this._keydown) + }, + + destroyed() { + window.removeEventListener("beforeunload", this._beforeUnload) + document.removeEventListener("keydown", this._keydown) + } +} + const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { params: {_csrf_token: csrfToken, screen_width: window.innerWidth}, - hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, AnalyticsExport, ChartTooltip, DirtyGuard}, + hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, AnalyticsExport, ChartTooltip, DirtyGuard, EditorKeyboard}, }) // Show progress bar on live navigation and form submits diff --git a/lib/berrypod_web/live/admin/pages/editor.ex b/lib/berrypod_web/live/admin/pages/editor.ex index ac4861a..438b441 100644 --- a/lib/berrypod_web/live/admin/pages/editor.ex +++ b/lib/berrypod_web/live/admin/pages/editor.ex @@ -26,6 +26,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do |> assign(:page_data, page) |> assign(:blocks, page.blocks) |> assign(:allowed_blocks, allowed_blocks) + |> assign(:history, []) + |> assign(:future, []) |> assign(:dirty, false) |> assign(:show_picker, false) |> assign(:picker_filter, "") @@ -85,10 +87,8 @@ defmodule BerrypodWeb.Admin.Pages.Editor do {:ok, new_blocks, message} -> {:noreply, socket - |> assign(:blocks, new_blocks) - |> assign(:dirty, true) - |> assign(:show_picker, false) - |> assign(:live_region_message, message)} + |> apply_mutation(new_blocks, message) + |> assign(:show_picker, false)} :noop -> {:noreply, socket} @@ -101,10 +101,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do {:ok, new_blocks} -> - {:noreply, - socket - |> assign(:blocks, new_blocks) - |> assign(:dirty, true)} + {:noreply, apply_mutation(socket, new_blocks, "Settings updated")} :noop -> {:noreply, socket} @@ -191,8 +188,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do {:ok, new_blocks} -> {:noreply, socket - |> assign(:blocks, new_blocks) - |> assign(:dirty, true) + |> apply_mutation(new_blocks, "Image selected") |> assign(:image_picker_block_id, nil) |> assign(:image_picker_field_key, nil)} @@ -208,10 +204,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do ) do case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => ""}) do {:ok, new_blocks} -> - {:noreply, - socket - |> assign(:blocks, new_blocks) - |> assign(:dirty, true)} + {:noreply, apply_mutation(socket, new_blocks, "Image cleared")} :noop -> {:noreply, socket} @@ -282,16 +275,54 @@ defmodule BerrypodWeb.Admin.Pages.Editor do {:noreply, socket |> assign(:blocks, page.blocks) + |> assign(:history, []) + |> assign(:future, []) |> assign(:dirty, false) |> put_flash(:info, "Page reset to defaults")} end + def handle_event("undo", _params, socket) do + case socket.assigns.history do + [prev | rest] -> + future = [socket.assigns.blocks | socket.assigns.future] + + {:noreply, + socket + |> assign(:blocks, prev) + |> assign(:history, rest) + |> assign(:future, future) + |> assign(:dirty, prev != socket.assigns.page_data.blocks) + |> assign(:live_region_message, "Undone")} + + [] -> + {:noreply, socket} + end + end + + def handle_event("redo", _params, socket) do + case socket.assigns.future do + [next | rest] -> + history = [socket.assigns.blocks | socket.assigns.history] + + {:noreply, + socket + |> assign(:blocks, next) + |> assign(:history, history) + |> assign(:future, rest) + |> assign(:dirty, true) + |> assign(:live_region_message, "Redone")} + + [] -> + {:noreply, socket} + end + end + # ── Render ─────────────────────────────────────────────────────── @impl true def render(assigns) do ~H""" -