replace admin rail with unified bottom sheet editor
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:
jamey
2026-03-07 09:30:07 +00:00
parent dbcecc7878
commit f4f036b84b
12 changed files with 1232 additions and 474 deletions

View File

@@ -32,16 +32,16 @@ defmodule BerrypodWeb.PageRenderer do
live page editor), wraps the page in a sidebar + content layout.
"""
def render_page(assigns) do
if assigns[:editing] && assigns[:editing_blocks] do
render_page_with_editor(assigns)
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
if assigns[:is_admin] do
render_page_with_rail(assigns)
else
render_page_normal(assigns)
end
end
defp render_page_normal(assigns) do
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
~H"""
<.shop_layout
{layout_assigns(assigns)}
@@ -57,146 +57,156 @@ defmodule BerrypodWeb.PageRenderer do
"""
end
defp render_page_with_editor(assigns) do
assigns = assign(assigns, :block_assigns, block_assigns(assigns))
defp render_page_with_rail(assigns) do
~H"""
<div
id="page-editor-live"
class="page-editor-live"
phx-hook="EditorKeyboard"
data-dirty={to_string(@editor_dirty)}
data-event-prefix="editor_"
data-sidebar-open={to_string(@editor_sidebar_open)}
<.shop_layout
{layout_assigns(assigns)}
active_page={@page.slug}
error_page={@page.slug == "error"}
>
<aside class="page-editor-sidebar" aria-label="Page editor">
<div class="page-editor-sidebar-header">
<h2 class="page-editor-sidebar-title">{@page.title}</h2>
<div class="page-editor-sidebar-actions">
<button
phx-click="editor_undo"
class={[
"admin-btn admin-btn-sm admin-btn-ghost",
@editor_history == [] && "opacity-30"
]}
disabled={@editor_history == []}
aria-label="Undo"
>
<.icon name="hero-arrow-uturn-left" class="size-4" />
</button>
<button
phx-click="editor_redo"
class={[
"admin-btn admin-btn-sm admin-btn-ghost",
@editor_future == [] && "opacity-30"
]}
disabled={@editor_future == []}
aria-label="Redo"
>
<.icon name="hero-arrow-uturn-right" class="size-4" />
</button>
<button
phx-click="editor_save"
class={[
"admin-btn admin-btn-sm admin-btn-primary",
!@editor_dirty && "opacity-50"
]}
disabled={!@editor_dirty}
>
Save
</button>
<button
phx-click="editor_reset_defaults"
data-confirm="Reset this page to its default layout? Your changes will be lost."
class="admin-btn admin-btn-sm admin-btn-ghost"
>
Reset
</button>
<button
phx-click="editor_done"
class="admin-btn admin-btn-sm admin-btn-ghost"
data-confirm={@editor_dirty && "You have unsaved changes. Leave without saving?"}
>
Done
</button>
<main id="main-content" class={page_main_class(@page.slug)}>
<%= if @editing && @editing_blocks do %>
<div
:for={block <- @editing_blocks}
:key={block["id"]}
data-block-type={block["type"]}
>
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
</div>
</div>
<%!-- ARIA live region for screen reader announcements --%>
<div aria-live="polite" aria-atomic="true" class="sr-only">
{if @editor_live_region_message, do: @editor_live_region_message}
</div>
<%!-- Unsaved changes indicator --%>
<p :if={@editor_dirty} class="admin-badge admin-badge-warning page-editor-sidebar-dirty">
Unsaved changes
</p>
<%!-- Block list --%>
<div class="block-list" role="list" aria-label="Page blocks">
<.block_card
:for={{block, idx} <- Enum.with_index(@editing_blocks)}
block={block}
idx={idx}
total={length(@editing_blocks)}
expanded={@editor_expanded}
event_prefix="editor_"
/>
<div :if={@editing_blocks == []} class="block-list-empty">
<p>No blocks on this page yet.</p>
<% else %>
<div :for={block <- @page.blocks} :key={block["id"]} data-block-type={block["type"]}>
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
</div>
</div>
<% end %>
</main>
</.shop_layout>
<%!-- Add block button --%>
<div class="block-actions">
<button phx-click="editor_show_picker" class="admin-btn admin-btn-outline block-add-btn">
<.icon name="hero-plus" class="size-4" /> Add block
<%!-- Editor sheet for page editing --%>
<.editor_sheet
editing={@editing}
editor_dirty={@editor_dirty}
editor_sheet_state={assigns[:editor_sheet_state] || :collapsed}
editor_save_status={@editor_save_status}
>
<.editor_sheet_content
page={@page}
editing_blocks={@editing_blocks}
editor_history={@editor_history}
editor_future={@editor_future}
editor_dirty={@editor_dirty}
editor_live_region_message={@editor_live_region_message}
editor_expanded={@editor_expanded}
editor_show_picker={@editor_show_picker}
editor_picker_filter={@editor_picker_filter}
editor_allowed_blocks={@editor_allowed_blocks}
editor_image_picker_block_id={@editor_image_picker_block_id}
editor_image_picker_images={@editor_image_picker_images}
editor_image_picker_search={@editor_image_picker_search}
editor_at_defaults={Map.get(assigns, :editor_at_defaults, true)}
/>
</.editor_sheet>
"""
end
# Editor sheet content - the block list and editing controls
attr :page, :map, required: true
attr :editing_blocks, :list, default: nil
attr :editor_history, :list, default: []
attr :editor_future, :list, default: []
attr :editor_dirty, :boolean, default: false
attr :editor_live_region_message, :string, default: nil
attr :editor_expanded, :any, default: nil
attr :editor_show_picker, :boolean, default: false
attr :editor_picker_filter, :string, default: ""
attr :editor_allowed_blocks, :list, default: nil
attr :editor_image_picker_block_id, :string, default: nil
attr :editor_image_picker_images, :list, default: []
attr :editor_image_picker_search, :string, default: ""
attr :editor_at_defaults, :boolean, default: true
defp editor_sheet_content(assigns) do
~H"""
<div id="editor-sheet-inner" phx-hook="EditorKeyboard" data-dirty={to_string(@editor_dirty)}>
<%!-- Page title and undo/redo --%>
<div class="editor-sheet-page-header">
<h3 class="editor-sheet-page-title">{@page.title}</h3>
<div class="editor-sheet-undo-redo">
<button
phx-click="editor_undo"
class={[
"admin-btn admin-btn-sm admin-btn-ghost",
@editor_history == [] && "opacity-30"
]}
disabled={@editor_history == []}
aria-label="Undo"
>
<.icon name="hero-arrow-uturn-left" class="size-4" />
</button>
<button
phx-click="editor_redo"
class={[
"admin-btn admin-btn-sm admin-btn-ghost",
@editor_future == [] && "opacity-30"
]}
disabled={@editor_future == []}
aria-label="Redo"
>
<.icon name="hero-arrow-uturn-right" class="size-4" />
</button>
</div>
</div>
<%!-- Block picker modal --%>
<.block_picker
:if={@editor_show_picker}
allowed_blocks={@editor_allowed_blocks}
filter={@editor_picker_filter}
<%!-- ARIA live region for screen reader announcements --%>
<div aria-live="polite" aria-atomic="true" class="sr-only">
{if @editor_live_region_message, do: @editor_live_region_message}
</div>
<%!-- Block list --%>
<div class="block-list" role="list" aria-label="Page blocks">
<.block_card
:for={{block, idx} <- Enum.with_index(@editing_blocks || [])}
block={block}
idx={idx}
total={length(@editing_blocks || [])}
expanded={@editor_expanded || MapSet.new()}
event_prefix="editor_"
/>
<%!-- Image picker modal --%>
<.image_picker
:if={@editor_image_picker_block_id}
images={@editor_image_picker_images}
search={@editor_image_picker_search}
event_prefix="editor_"
/>
</aside>
<div :if={(@editing_blocks || []) == []} class="block-list-empty">
<p>No blocks on this page yet.</p>
</div>
</div>
<%!-- Backdrop: tapping the page dismisses the sidebar --%>
<div
:if={@editor_sidebar_open}
class="page-editor-backdrop"
phx-click="editor_toggle_sidebar"
aria-hidden="true"
<%!-- Add block button --%>
<div class="block-actions">
<button phx-click="editor_show_picker" class="admin-btn admin-btn-outline block-add-btn">
<.icon name="hero-plus" class="size-4" /> Add block
</button>
<button
phx-click="editor_reset_defaults"
data-confirm="Reset this page to its default blocks? You can undo this."
class="admin-btn admin-btn-sm admin-btn-ghost"
disabled={@editor_at_defaults}
>
<.icon name="hero-arrow-path" class="size-4" /> Reset to defaults
</button>
</div>
<%!-- Block picker modal --%>
<.block_picker
:if={@editor_show_picker}
allowed_blocks={@editor_allowed_blocks}
filter={@editor_picker_filter}
event_prefix="editor_"
/>
<div class="page-editor-content">
<.shop_layout
{layout_assigns(assigns)}
active_page={@page.slug}
error_page={@page.slug == "error"}
>
<main id="main-content" class={page_main_class(@page.slug)}>
<div
:for={block <- @editing_blocks}
:key={block["id"]}
data-block-type={block["type"]}
>
{render_block(Map.merge(@block_assigns, %{block: block, page_slug: @page.slug}))}
</div>
</main>
</.shop_layout>
</div>
<%!-- Image picker modal --%>
<.image_picker
:if={@editor_image_picker_block_id}
images={@editor_image_picker_images}
search={@editor_image_picker_search}
event_prefix="editor_"
/>
</div>
"""
end