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

322 lines
9.1 KiB
Markdown

# 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:
```elixir
|> 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`:
```elixir
|> 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
```elixir
|> 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`:
```elixir
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
```elixir
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
```elixir
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:
```heex
<button role="tab" ...>
Page
<span :if={@page_dirty} class="editor-tab-dirty-dot" aria-label="unsaved changes"></span>
</button>
```
CSS:
```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:
```heex
<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`:
```javascript
// 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
```heex
<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
```elixir
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
```elixir
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