berrypod/docs/plans/unified-editor-session.md
jamey 638bb4fb70
Some checks failed
deploy / deploy (push) Has been cancelled
add Site context with social links editor and site-wide settings
- Add Site context for managing site-wide content (social links, nav items,
  announcement bar, footer content)
- Add SocialLink schema with URL normalization and platform auto-detection
  supporting 40+ platforms via host and 25+ via URI scheme
- Add NavItem schema for header/footer navigation (editor UI coming next)
- Add SiteEditor component with collapsible sections for each content type
- Wire social links card block and footer to use database data
- Filter empty URLs from display in shop components
- Add DetailsPreserver hook to preserve collapsible section state
- Add comprehensive tests for Site context and SocialLink functions
- Remove unused helper functions from onboarding to fix compiler warnings
- Move sync_edit_url_param helper to group handle_editor_event clauses

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-28 10:09:33 +00:00

9.1 KiB

Unified Editor Session

Status: Complete Depends on: Editor panel reorganisation (Phase 1-2 complete) Estimated effort: 4-5 hours

Overview

Unify the editing experience across all three tabs (Page, Theme, Site) so they behave consistently:

  • Instant preview — all changes show immediately on the shop
  • Explicit save — changes only persist to database when you click Save
  • Free tab switching — no warnings when switching between tabs
  • Accumulated changes — edits across all tabs are saved together
  • Navigation warning — warn on page navigation or refresh if any tab has unsaved changes

Current State

Tab Dirty Tracking Save Method Preview
Page editor_dirty Manual Save Instant (in-memory blocks)
Theme None Auto-save Instant (CSS regenerated)
Site None Auto-save Instant (assigns updated)

Target State

Tab Dirty Tracking Save Method Preview
Page page_dirty Unified Save Instant (in-memory blocks)
Theme theme_dirty Unified Save Instant (in-memory settings)
Site site_dirty Unified Save Instant (in-memory settings)

Unified dirty flag: editor_dirty = page_dirty || theme_dirty || site_dirty

Implementation Tasks

Phase 1: Theme Tab — Preview Without Auto-Save (1.5h)

Currently Theme auto-saves every change. We need to decouple preview from persistence.

1.1 Add theme dirty tracking

In page_editor_hook.ex, add assigns:

|> assign(:theme_dirty, false)
|> assign(:theme_editor_original, nil)  # snapshot at load time

1.2 Modify theme event handlers

Change handlers like theme_update_setting, theme_update_color, etc. to:

  1. Update theme_editor_settings in memory (for preview)
  2. Regenerate CSS for live preview
  3. Set theme_dirty = true
  4. Do NOT call Settings.update_theme_settings()

1.3 Store original theme state

When loading theme state in load_theme_state/1:

|> assign(:theme_editor_original, theme_settings)

1.4 Add theme revert logic

On discard/close with dirty state, restore from theme_editor_original.

Phase 2: Site Tab — Preview Without Auto-Save (1h)

2.1 Add site dirty tracking

|> assign(:site_dirty, false)
|> assign(:site_editor_original, nil)

2.2 Modify site event handlers

Change handle_site_update/2 to:

  1. Update site_* assigns in memory (for preview)
  2. Set site_dirty = true
  3. Do NOT call Site.put_announcement() or Site.put_footer_content()

2.3 Store original site state

When loading site state in load_site_state/1:

original = %{
  announcement_text: site_settings.announcement_text,
  announcement_link: site_settings.announcement_link,
  announcement_style: site_settings.announcement_style,
  footer_about: site_settings.footer_about,
  footer_copyright: site_settings.footer_copyright,
  show_newsletter: site_settings.show_newsletter
}
|> assign(:site_editor_original, original)

Phase 3: Unified Save Button (1h)

3.1 Compute unified dirty state

defp compute_editor_dirty(socket) do
  page_dirty = socket.assigns[:editor_dirty] || false
  theme_dirty = socket.assigns[:theme_dirty] || false
  site_dirty = socket.assigns[:site_dirty] || false
  settings_dirty = socket.assigns[:settings_dirty] || false

  assign(socket, :any_dirty, page_dirty || theme_dirty || site_dirty || settings_dirty)
end

3.2 Update Save button

In editor_sheet header, change Save button to:

  • Enabled when @any_dirty
  • On click: editor_save_all event

3.3 Implement unified save handler

defp handle_editor_event("editor_save_all", _params, socket) do
  socket = save_all_tabs(socket)
  {:halt, socket}
end

defp save_all_tabs(socket) do
  socket
  |> maybe_save_page()
  |> maybe_save_theme()
  |> maybe_save_site()
  |> maybe_save_settings()
  |> assign(:editor_save_status, :saved)
  |> schedule_save_status_clear()
end

defp maybe_save_page(socket) do
  if socket.assigns[:editor_dirty] do
    # existing page save logic
  else
    socket
  end
end

defp maybe_save_theme(socket) do
  if socket.assigns[:theme_dirty] do
    Settings.update_theme_settings(socket.assigns.theme_editor_settings)
    assign(socket, :theme_dirty, false)
  else
    socket
  end
end

defp maybe_save_site(socket) do
  if socket.assigns[:site_dirty] do
    site = socket.assigns
    Site.put_announcement(site.site_announcement_text, site.site_announcement_link, site.site_announcement_style)
    Site.put_footer_content(site.site_footer_about, site.site_footer_copyright, site.site_footer_show_newsletter)
    assign(socket, :site_dirty, false)
  else
    socket
  end
end

Phase 4: Visual Indicators (0.5h)

4.1 Dirty dots on tab labels

In editor_sheet tablist:

<button role="tab" ...>
  Page
  <span :if={@page_dirty} class="editor-tab-dirty-dot" aria-label="unsaved changes"></span>
</button>

CSS:

.editor-tab-dirty-dot {
  display: inline-block;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--color-warning);
  margin-left: 4px;
}

4.2 Update EditorKeyboard hook

Pass unified dirty state to the hook:

<div id="editor-sheet-inner"
     phx-hook="EditorKeyboard"
     data-dirty={to_string(@any_dirty)}>

Phase 5: Navigation Warning Flow (1h)

5.1 Update EditorKeyboard JS hook

Modify the navigation intercept in assets/js/app.js:

// On navigation attempt when dirty
if (this.el.dataset.dirty === "true") {
  e.preventDefault();
  e.stopImmediatePropagation();

  // Send event to LiveView to show modal
  this.pushEvent("editor_nav_blocked", { href: href });
}

5.2 Add navigation modal component

<dialog :if={@editor_nav_blocked} class="editor-nav-modal" open>
  <div class="editor-nav-modal-content">
    <h3>Unsaved changes</h3>
    <p>You have unsaved changes. What would you like to do?</p>
    <div class="editor-nav-modal-actions">
      <button phx-click="editor_save_and_navigate">Save & continue</button>
      <button phx-click="editor_discard_and_navigate">Discard & continue</button>
      <button phx-click="editor_cancel_navigate">Cancel</button>
    </div>
  </div>
</dialog>

5.3 Handle modal events

defp handle_editor_event("editor_nav_blocked", %{"href" => href}, socket) do
  {:halt, assign(socket, editor_nav_blocked: href)}
end

defp handle_editor_event("editor_save_and_navigate", _params, socket) do
  socket = save_all_tabs(socket)
  {:halt, push_navigate(socket, to: socket.assigns.editor_nav_blocked)}
end

defp handle_editor_event("editor_discard_and_navigate", _params, socket) do
  socket = revert_all_tabs(socket)
  {:halt, push_navigate(socket, to: socket.assigns.editor_nav_blocked)}
end

defp handle_editor_event("editor_cancel_navigate", _params, socket) do
  # Re-open sheet if collapsed
  socket =
    socket
    |> assign(:editor_nav_blocked, nil)
    |> assign(:editor_sheet_state, :open)
  {:halt, socket}
end

5.4 Add revert logic

defp revert_all_tabs(socket) do
  socket
  |> maybe_revert_page()
  |> maybe_revert_theme()
  |> maybe_revert_site()
  |> assign(:any_dirty, false)
end

defp maybe_revert_theme(socket) do
  if socket.assigns[:theme_dirty] do
    original = socket.assigns.theme_editor_original
    # Regenerate CSS from original
    # Reset theme_editor_settings to original
  else
    socket
  end
end

Files to Modify

File Changes
lib/berrypod_web/page_editor_hook.ex Dirty tracking, save handlers, revert logic
lib/berrypod_web/components/shop_components/layout.ex Tab dirty dots, Save button, nav modal
lib/berrypod_web/page_renderer.ex Pass unified dirty state to editor_sheet
assets/js/app.js EditorKeyboard nav intercept
assets/css/admin/components.css Dirty dot styles, nav modal styles

Testing Plan

  1. Page tab: Edit blocks → switch tabs → switch back → verify changes preserved
  2. Theme tab: Change color → switch tabs → verify preview still shows new color
  3. Site tab: Edit announcement → switch tabs → verify preview shows new text
  4. Save all: Make changes in all tabs → click Save → verify all persisted
  5. Navigate away: Make changes → click link → verify warning modal
  6. Save & continue: Warning modal → Save & continue → verify saved and navigated
  7. Discard & continue: Warning modal → Discard → verify reverted and navigated
  8. Cancel: Warning modal → Cancel → verify sheet opens, changes preserved
  9. Browser refresh: Make changes → F5 → verify beforeunload warning
  10. Tab close: Make changes → close tab → verify beforeunload warning

Rollback Plan

If issues arise, the immediate fix is to re-enable auto-save for Theme/Site tabs by calling the persistence functions in the event handlers again. The dirty tracking can remain in place.

Future Enhancements

  • Undo/redo for Theme/Site: Currently only Page tab has history
  • Draft persistence: Save drafts to localStorage for recovery after crash
  • Keyboard shortcuts: Cmd+S to save all