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:
@@ -16,10 +16,10 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
"""
|
||||
|
||||
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.{BlockEditor, BlockTypes}
|
||||
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
|
||||
|
||||
def on_mount(:mount_page_editor, _params, _session, socket) do
|
||||
socket =
|
||||
@@ -27,6 +27,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editing, false)
|
||||
|> assign(:editing_blocks, nil)
|
||||
|> assign(:editor_dirty, false)
|
||||
|> assign(:editor_at_defaults, true)
|
||||
|> assign(:editor_history, [])
|
||||
|> assign(:editor_future, [])
|
||||
|> assign(:editor_expanded, MapSet.new())
|
||||
@@ -36,10 +37,12 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_live_region_message, nil)
|
||||
|> assign(:editor_current_path, nil)
|
||||
|> assign(:editor_sidebar_open, true)
|
||||
|> assign(:editor_sheet_state, :collapsed)
|
||||
|> assign(:editor_image_picker_block_id, nil)
|
||||
|> assign(:editor_image_picker_field_key, nil)
|
||||
|> assign(:editor_image_picker_images, [])
|
||||
|> assign(:editor_image_picker_search, "")
|
||||
|> assign(:editor_save_status, :idle)
|
||||
|> attach_hook(:editor_params, :handle_params, &handle_editor_params/3)
|
||||
|> attach_hook(:editor_events, :handle_event, &handle_editor_event/3)
|
||||
|> attach_hook(:editor_info, :handle_info, &handle_editor_info/2)
|
||||
@@ -47,50 +50,48 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
{:cont, socket}
|
||||
end
|
||||
|
||||
# ── handle_params: detect ?edit=true ─────────────────────────────
|
||||
# ── handle_params: track current path ────────────────────────────
|
||||
|
||||
defp handle_editor_params(_params, uri, socket) do
|
||||
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
|
||||
socket = 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
|
||||
# Store the current path for reference (e.g. the Done button)
|
||||
{:cont, assign(socket, :editor_current_path, parsed.path)}
|
||||
end
|
||||
|
||||
# ── handle_info: deferred init ───────────────────────────────────
|
||||
# ── handle_info ─────────────────────────────────────────────────
|
||||
|
||||
defp handle_editor_info(:editor_deferred_init, socket) do
|
||||
if socket.assigns.editing and is_nil(socket.assigns.editing_blocks) and socket.assigns[:page] do
|
||||
{:halt, enter_edit_mode(socket)}
|
||||
else
|
||||
{:cont, socket}
|
||||
end
|
||||
defp handle_editor_info(:editor_clear_save_status, socket) do
|
||||
{:halt, assign(socket, :editor_save_status, :idle)}
|
||||
end
|
||||
|
||||
defp handle_editor_info(_msg, socket), do: {:cont, socket}
|
||||
|
||||
# ── 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
|
||||
if socket.assigns.editing do
|
||||
handle_editor_action(action, params, socket)
|
||||
@@ -330,6 +331,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
case socket.assigns.editor_history do
|
||||
[prev | rest] ->
|
||||
future = [socket.assigns.editing_blocks | socket.assigns.editor_future]
|
||||
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, prev)
|
||||
|
||||
socket =
|
||||
socket
|
||||
@@ -337,6 +339,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_history, rest)
|
||||
|> assign(:editor_future, future)
|
||||
|> assign(:editor_dirty, true)
|
||||
|> assign(:editor_at_defaults, at_defaults)
|
||||
|> assign(:editor_live_region_message, "Undone")
|
||||
|> reload_block_data(prev)
|
||||
|
||||
@@ -351,6 +354,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
case socket.assigns.editor_future do
|
||||
[next | rest] ->
|
||||
history = [socket.assigns.editing_blocks | socket.assigns.editor_history]
|
||||
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, next)
|
||||
|
||||
socket =
|
||||
socket
|
||||
@@ -358,6 +362,7 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_history, history)
|
||||
|> assign(:editor_future, rest)
|
||||
|> assign(:editor_dirty, true)
|
||||
|> assign(:editor_at_defaults, at_defaults)
|
||||
|> assign(:editor_live_region_message, "Redone")
|
||||
|> reload_block_data(next)
|
||||
|
||||
@@ -376,39 +381,32 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
case Pages.save_page(page.slug, %{title: page.title, blocks: blocks}) do
|
||||
{:ok, _saved_page} ->
|
||||
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
|
||||
|> assign(:page, updated_page)
|
||||
|> assign(:editing_blocks, updated_page.blocks)
|
||||
|> assign(:editor_dirty, false)
|
||||
|> assign(:editor_at_defaults, at_defaults)
|
||||
|> assign(:editor_history, [])
|
||||
|> assign(:editor_future, [])
|
||||
|> put_flash(:info, "Page saved")
|
||||
|> assign(:editor_save_status, :saved)
|
||||
|
||||
{:halt, socket}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:halt, put_flash(socket, :error, "Failed to save page")}
|
||||
{:halt, assign(socket, :editor_save_status, :error)}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_editor_action("reset_defaults", _params, socket) do
|
||||
slug = socket.assigns.page.slug
|
||||
:ok = Pages.reset_page(slug)
|
||||
page = Pages.get_page(slug)
|
||||
default_blocks = Berrypod.Pages.Defaults.for_slug(slug).blocks
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> 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}
|
||||
# Treat reset like any other mutation: push to history, mark dirty
|
||||
{:halt, apply_mutation(socket, default_blocks, "Reset to defaults", :content)}
|
||||
end
|
||||
|
||||
defp handle_editor_action("done", _params, socket) do
|
||||
@@ -424,11 +422,13 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
defp enter_edit_mode(socket) do
|
||||
page = socket.assigns.page
|
||||
allowed = BlockTypes.allowed_for(page.slug)
|
||||
at_defaults = Defaults.matches_defaults?(page.slug, page.blocks)
|
||||
|
||||
socket
|
||||
|> assign(:editing, true)
|
||||
|> assign(:editing_blocks, page.blocks)
|
||||
|> assign(:editor_dirty, false)
|
||||
|> assign(:editor_at_defaults, at_defaults)
|
||||
|> assign(:editor_history, [])
|
||||
|> assign(:editor_future, [])
|
||||
|> assign(:editor_expanded, MapSet.new())
|
||||
@@ -437,10 +437,12 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_allowed_blocks, allowed)
|
||||
|> assign(:editor_live_region_message, nil)
|
||||
|> assign(:editor_sidebar_open, true)
|
||||
|> assign(:editor_sheet_state, :open)
|
||||
|> assign(:editor_image_picker_block_id, nil)
|
||||
|> assign(:editor_image_picker_field_key, nil)
|
||||
|> assign(:editor_image_picker_images, [])
|
||||
|> assign(:editor_image_picker_search, "")
|
||||
|> assign(:editor_save_status, :idle)
|
||||
end
|
||||
|
||||
defp exit_edit_mode(socket) do
|
||||
@@ -456,22 +458,27 @@ defmodule BerrypodWeb.PageEditorHook do
|
||||
|> assign(:editor_allowed_blocks, nil)
|
||||
|> assign(:editor_live_region_message, nil)
|
||||
|> assign(:editor_sidebar_open, true)
|
||||
|> assign(:editor_sheet_state, :collapsed)
|
||||
|> assign(:editor_image_picker_block_id, nil)
|
||||
|> assign(:editor_image_picker_field_key, nil)
|
||||
|> assign(:editor_image_picker_images, [])
|
||||
|> assign(:editor_image_picker_search, "")
|
||||
|> assign(:editor_save_status, :idle)
|
||||
end
|
||||
|
||||
defp apply_mutation(socket, new_blocks, message, type) do
|
||||
history =
|
||||
[socket.assigns.editing_blocks | socket.assigns.editor_history] |> Enum.take(50)
|
||||
|
||||
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, new_blocks)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:editing_blocks, new_blocks)
|
||||
|> assign(:editor_history, history)
|
||||
|> assign(:editor_future, [])
|
||||
|> assign(:editor_dirty, true)
|
||||
|> assign(:editor_at_defaults, at_defaults)
|
||||
|> assign(:editor_live_region_message, message)
|
||||
|
||||
case type do
|
||||
|
||||
Reference in New Issue
Block a user