implement unified on-site editor phases 1-2
All checks were successful
deploy / deploy (push) Successful in 1m10s

Add theme editing to the existing PageEditorHook, enabling on-site
theme customisation alongside page editing. The editor panel now has
three tabs (Page, Theme, Settings) and can be collapsed while
keeping editing state intact.

- Add theme editing state and event handlers to PageEditorHook
- Add 3-tab UI with tab switching logic
- Add transparent overlay for click-outside dismiss
- Add mobile drag-to-resize with height persistence
- Fix animation replay on drag release (has-dragged class)
- Preserve panel height across LiveView re-renders
- Default to Page tab on editable pages, Theme otherwise
- Show unsaved changes indicator on FAB when panel collapsed
- Fix handle_event grouping warning in admin theme

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-03-09 09:01:21 +00:00
parent 74ab6411f7
commit 168b6ce76f
10 changed files with 954 additions and 53 deletions

View File

@@ -48,10 +48,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
# Keys accepted by shop_layout — used by layout_assigns/1 so page templates
# can spread assigns without listing each one explicitly.
@layout_keys ~w(theme_settings site_name logo_image header_image mode cart_items cart_count
@layout_keys ~w(theme_settings generated_css site_name logo_image header_image mode cart_items cart_count
cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
search_query search_results search_open categories shipping_estimate
country_code available_countries editing editor_current_path editor_sidebar_open
country_code available_countries editing theme_editing editor_current_path editor_sidebar_open
editor_active_tab editor_sheet_state editor_dirty editor_save_status
header_nav_items footer_nav_items newsletter_enabled newsletter_state stripe_connected)a
@doc """
@@ -102,6 +103,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :newsletter_enabled, :boolean, default: false
attr :newsletter_state, :atom, default: :idle
attr :stripe_connected, :boolean, default: true
attr :generated_css, :string, default: nil
slot :inner_block, required: true
@@ -110,9 +112,24 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<div
id={unless @error_page, do: "shop-container"}
phx-hook={unless @error_page, do: "CartPersist"}
class="shop-container"
class="shop-container themed"
data-bottom-nav={!@error_page || nil}
data-mood={@theme_settings.mood}
data-typography={@theme_settings.typography}
data-shape={@theme_settings.shape}
data-density={@theme_settings.density}
data-grid={@theme_settings.grid_columns}
data-header={@theme_settings.header_layout}
data-sticky={to_string(@theme_settings.sticky_header)}
data-layout={@theme_settings.layout_width}
data-shadow={@theme_settings.card_shadow}
data-button-style={@theme_settings.button_style}
>
<%!-- Live-updatable theme CSS (overrides static version in head) --%>
<%= if @generated_css do %>
{Phoenix.HTML.raw("<style id=\"theme-css-live\">#{@generated_css}</style>")}
<% end %>
<.skip_link />
<%= if @theme_settings.announcement_bar do %>
@@ -1032,7 +1049,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
# ── Editor sheet ────────────────────────────────────────────────────
@doc """
Renders the unified editor sheet for page editing.
Renders the unified editor sheet for page/theme/settings editing.
The sheet is anchored to the bottom edge on mobile (<768px) and the right edge
on desktop (≥768px). It has three states on mobile (collapsed, partial, full)
@@ -1040,59 +1057,98 @@ defmodule BerrypodWeb.ShopComponents.Layout do
## Attributes
* `editing` - Whether edit mode is active.
* `editor_dirty` - Whether there are unsaved changes.
* `editing` - Whether page edit mode is active.
* `theme_editing` - Whether theme edit mode is active.
* `editor_dirty` - Whether there are unsaved page changes.
* `editor_sheet_state` - Current state (:collapsed, :partial, :full, or :open).
* `editor_active_tab` - Current tab (:page, :theme, :settings).
* `has_editable_page` - Whether the current page has editable blocks.
## Slots
* `inner_block` - The editor content (block list, settings, etc.).
"""
attr :editing, :boolean, default: false
attr :theme_editing, :boolean, default: false
attr :editor_dirty, :boolean, default: false
attr :editor_sheet_state, :atom, default: :collapsed
attr :editor_save_status, :atom, default: :idle
attr :editor_active_tab, :atom, default: :page
attr :has_editable_page, :boolean, default: false
slot :inner_block
def editor_sheet(assigns) do
# Determine panel title based on active tab
title =
case assigns.editor_active_tab do
:page -> "Page"
:theme -> "Theme"
:settings -> "Settings"
end
# Any editing mode active
any_editing = assigns.editing || assigns.theme_editing
assigns =
assigns
|> assign(:title, title)
|> assign(:any_editing, any_editing)
~H"""
<%!-- Floating action button: always visible when panel is closed --%>
<button
:if={@editor_sheet_state == :collapsed}
type="button"
phx-click={if @editing, do: "editor_set_sheet_state", else: "editor_toggle_editing"}
phx-value-state={if @editing, do: "open", else: nil}
phx-click={if @any_editing, do: "editor_set_sheet_state", else: "editor_set_tab"}
phx-value-state={if @any_editing, do: "open", else: nil}
phx-value-tab={
if @any_editing, do: nil, else: if(@has_editable_page, do: "page", else: "theme")
}
class="editor-fab"
aria-label={if @editing, do: "Show editor", else: "Edit page"}
aria-label={if @any_editing, do: "Show editor", else: "Edit"}
>
<.edit_pencil_svg />
<span>{if @editing, do: "Show editor", else: "Edit page"}</span>
<span>{if @any_editing, do: "Show editor", else: "Edit"}</span>
<span :if={@editing && @editor_dirty} class="editor-fab-dirty" aria-label="Unsaved changes" />
</button>
<%!-- Overlay to catch taps outside the panel --%>
<div
:if={@editor_sheet_state == :open}
class="editor-overlay"
phx-click="editor_set_sheet_state"
phx-value-state="collapsed"
aria-hidden="true"
/>
<%!-- Editor panel: slides in/out --%>
<aside
id="editor-panel"
class="editor-panel"
role="region"
aria-label="Page editor"
aria-label="Site editor"
aria-hidden={to_string(@editor_sheet_state == :collapsed)}
data-state={@editor_sheet_state}
data-editing={to_string(@editing)}
data-editing={to_string(@any_editing)}
phx-hook="EditorSheet"
>
<%!-- Drag handle for mobile resizing --%>
<div class="editor-panel-drag-handle" data-drag-handle>
<div class="editor-panel-drag-handle-bar" />
</div>
<div class="editor-panel-header">
<div class="editor-panel-header-left">
<span class="editor-panel-title">Page editor</span>
<span :if={@editor_dirty} class="editor-panel-dirty" aria-live="polite">
<span class="editor-panel-title">{@title}</span>
<span :if={@editing && @editor_dirty} class="editor-panel-dirty" aria-live="polite">
<span class="editor-panel-dirty-dot" aria-hidden="true" />
<span>Unsaved</span>
</span>
</div>
<div class="editor-panel-header-actions">
<button
:if={@editor_save_status == :saved}
:if={@editor_active_tab == :page && @editor_save_status == :saved}
type="button"
class="admin-btn admin-btn-sm admin-btn-ghost"
disabled
@@ -1100,7 +1156,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
Saved ✓
</button>
<button
:if={@editor_save_status != :saved}
:if={@editor_active_tab == :page && @editor_save_status != :saved}
type="button"
phx-click="editor_save"
class={["admin-btn admin-btn-sm", @editor_dirty && "admin-btn-primary"]}
@@ -1108,9 +1164,66 @@ defmodule BerrypodWeb.ShopComponents.Layout do
>
Save
</button>
<button
type="button"
phx-click="editor_set_sheet_state"
phx-value-state="collapsed"
class="editor-panel-close"
aria-label="Close editor"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
width="20"
height="20"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<%!-- Tab bar --%>
<div class="editor-tabs" role="tablist">
<button
type="button"
role="tab"
phx-click="editor_set_tab"
phx-value-tab="page"
class={["editor-tab", @editor_active_tab == :page && "editor-tab-active"]}
aria-selected={to_string(@editor_active_tab == :page)}
disabled={!@has_editable_page}
title={
if @has_editable_page, do: "Edit page blocks", else: "This page has no editable blocks"
}
>
Page
</button>
<button
type="button"
role="tab"
phx-click="editor_set_tab"
phx-value-tab="theme"
class={["editor-tab", @editor_active_tab == :theme && "editor-tab-active"]}
aria-selected={to_string(@editor_active_tab == :theme)}
>
Theme
</button>
<button
type="button"
role="tab"
phx-click="editor_set_tab"
phx-value-tab="settings"
class={["editor-tab", @editor_active_tab == :settings && "editor-tab-active"]}
aria-selected={to_string(@editor_active_tab == :settings)}
>
Settings
</button>
</div>
<div class="editor-panel-content">
{render_slot(@inner_block)}
</div>

View File

@@ -326,10 +326,6 @@ defmodule BerrypodWeb.Admin.Theme.Index do
end
end
defp has_valid_logo?(socket) do
socket.assigns.theme_settings.show_logo && socket.assigns.logo_image != nil
end
@impl true
def handle_event("save_theme", _params, socket) do
socket = put_flash(socket, :info, "Theme saved successfully")
@@ -452,6 +448,10 @@ defmodule BerrypodWeb.Admin.Theme.Index do
{:noreply, socket}
end
defp has_valid_logo?(socket) do
socket.assigns.theme_settings.show_logo && socket.assigns.logo_image != nil
end
def error_to_string(:too_large), do: "File is too large"
def error_to_string(:too_many_files), do: "Too many files"
def error_to_string(:not_accepted), do: "File type not accepted"

View File

@@ -330,8 +330,7 @@
<div class="theme-subfield">
<form phx-change="update_setting" phx-value-field="favicon_short_name">
<label class="theme-slider-label theme-block-label">
Short name
<span class="admin-text-tertiary">— appears under home screen icon</span>
Short name <span class="admin-text-tertiary">— appears under home screen icon</span>
</label>
<input
type="text"
@@ -441,7 +440,7 @@
</div>
</div>
<% end %>
<!-- Header Image Controls -->
<div class="admin-stack admin-stack-md theme-subfield">
<form phx-change="update_setting" phx-value-field="header_zoom">
@@ -521,7 +520,7 @@
<% end %>
</div>
<% end %>
<!-- Presets Section -->
<div class="theme-section">
<label class="theme-section-label">Start with a preset</label>

View File

@@ -1,13 +1,15 @@
defmodule BerrypodWeb.PageEditorHook do
@moduledoc """
LiveView on_mount hook for the live page editor sidebar.
LiveView on_mount hook for the unified on-site editor.
Mounted in the public_shop live_session. When an admin visits any shop
page with `?edit=true` in the URL, this hook activates editing mode:
loads a working copy of the page's blocks, attaches event handlers for
block manipulation, and sets assigns that trigger the editor sidebar
in `PageRenderer.render_page/1`.
page, this hook enables editing capabilities:
1. **Page editing** — loads a working copy of the page's blocks, attaches
event handlers for block manipulation
2. **Theme editing** — provides live theme customisation on the actual shop
The hook manages a tabbed editor panel with Page, Theme, and Settings tabs.
Non-admin users are unaffected — the hook just assigns `editing: false`.
## Actions
@@ -18,12 +20,15 @@ defmodule BerrypodWeb.PageEditorHook do
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [attach_hook: 4, push_navigate: 2]
alias Berrypod.{Media, Settings}
alias Berrypod.Pages
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
alias Berrypod.Theme.{Contrast, CSSGenerator, Presets}
def on_mount(:mount_page_editor, _params, _session, socket) do
socket =
socket
# Page editing state
|> assign(:editing, false)
|> assign(:editing_blocks, nil)
|> assign(:editor_dirty, false)
@@ -43,6 +48,18 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editor_image_picker_images, [])
|> assign(:editor_image_picker_search, "")
|> assign(:editor_save_status, :idle)
# Unified editor tab state (:page | :theme | :settings)
|> assign(:editor_active_tab, :page)
# Theme editing state
|> assign(:theme_editing, false)
|> assign(:theme_editor_settings, nil)
|> assign(:theme_editor_active_preset, nil)
|> assign(:theme_editor_logo_image, nil)
|> assign(:theme_editor_header_image, nil)
|> assign(:theme_editor_icon_image, nil)
|> assign(:theme_editor_contrast_warning, :ok)
|> assign(:theme_editor_customise_open, false)
|> assign(:theme_editor_presets, Presets.all_with_descriptions())
|> 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)
@@ -84,7 +101,7 @@ defmodule BerrypodWeb.PageEditorHook do
# 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
if socket.assigns.is_admin do
state = if state_str == "open", do: :open, else: :collapsed
{:halt, assign(socket, :editor_sheet_state, state)}
else
@@ -92,6 +109,62 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
# Tab switching for unified editor
defp handle_editor_event("editor_set_tab", %{"tab" => tab_str}, socket) do
if socket.assigns.is_admin do
tab = String.to_existing_atom(tab_str)
socket =
case tab do
:theme ->
# Load theme state if not already loaded
if socket.assigns.theme_editing do
assign(socket, :editor_active_tab, :theme)
else
enter_theme_edit_mode(socket)
end
:page ->
# Enter page edit mode if we have a page and aren't already editing
socket = assign(socket, :editor_active_tab, :page)
if socket.assigns[:page] && !socket.assigns.editing do
enter_edit_mode(socket)
else
socket
end
:settings ->
# Ensure theme state is loaded for settings that need it
socket =
if socket.assigns.theme_editing do
socket
else
load_theme_state(socket)
end
assign(socket, :editor_active_tab, :settings)
end
{:halt, socket}
else
{:cont, socket}
end
end
# Toggle theme editing mode
defp handle_editor_event("editor_toggle_theme", _params, socket) do
if socket.assigns.is_admin do
if socket.assigns.theme_editing do
{:halt, exit_theme_edit_mode(socket)}
else
{:halt, enter_theme_edit_mode(socket)}
end
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)
@@ -100,6 +173,15 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
# Theme editing events
defp handle_editor_event("theme_" <> action, params, socket) do
if socket.assigns.is_admin && socket.assigns.theme_editing do
handle_theme_action(action, params, socket)
else
{:cont, socket}
end
end
defp handle_editor_event(_event, _params, socket), do: {:cont, socket}
# ── Block manipulation actions ───────────────────────────────────
@@ -417,6 +499,200 @@ defmodule BerrypodWeb.PageEditorHook do
# Catch-all for unknown editor actions
defp handle_editor_action(_action, _params, socket), do: {:halt, socket}
# ── Theme editing actions ───────────────────────────────────────────
# Settings stored outside the theme JSON (site_name, site_description)
@standalone_settings ~w(site_name site_description)
defp handle_theme_action("apply_preset", %{"preset" => preset_name}, socket) do
preset_atom = String.to_existing_atom(preset_name)
case Settings.apply_preset(preset_atom) do
{:ok, theme_settings} ->
generated_css =
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
header_image = socket.assigns.theme_editor_header_image
contrast_warning = compute_header_contrast(theme_settings, header_image)
socket =
socket
# Update editor state
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_editor_active_preset, preset_atom)
|> assign(:theme_editor_contrast_warning, contrast_warning)
# Update shop state so layout reflects changes live
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
{:halt, socket}
{:error, _} ->
{:halt, socket}
end
end
defp handle_theme_action(
"update_setting",
%{"field" => field, "setting_value" => value},
socket
)
when field in @standalone_settings do
Settings.put_setting(field, value, "string")
# Also update the main assigns so ThemeHook sees the change
{:halt, assign(socket, String.to_existing_atom(field), value)}
end
defp handle_theme_action(
"update_setting",
%{"field" => field, "setting_value" => value},
socket
) do
field_atom = String.to_existing_atom(field)
update_theme_setting(socket, %{field_atom => value}, field)
end
defp handle_theme_action("update_setting", %{"field" => field} = params, socket)
when field in @standalone_settings do
value = params[field]
if value do
Settings.put_setting(field, value, "string")
{:halt, assign(socket, String.to_existing_atom(field), value)}
else
{:halt, socket}
end
end
defp handle_theme_action("update_setting", %{"field" => field} = params, socket) do
value = params[field] || params["#{field}_text"] || params["value"]
if value do
field_atom = String.to_existing_atom(field)
update_theme_setting(socket, %{field_atom => value}, field)
else
{:halt, socket}
end
end
defp handle_theme_action("update_color", %{"field" => field, "value" => value}, socket) do
field_atom = String.to_existing_atom(field)
update_theme_setting(socket, %{field_atom => value}, field)
end
defp handle_theme_action("toggle_setting", %{"field" => field}, socket) do
field_atom = String.to_existing_atom(field)
current_value = Map.get(socket.assigns.theme_editor_settings, field_atom)
new_value = !current_value
# Prevent turning off show_site_name when there's no logo
if field_atom == :show_site_name && new_value == false && !has_valid_logo?(socket) do
{:halt, socket}
else
update_theme_setting(socket, %{field_atom => new_value}, field)
end
end
defp handle_theme_action("toggle_customise", _params, socket) do
{:halt,
assign(socket, :theme_editor_customise_open, !socket.assigns.theme_editor_customise_open)}
end
defp handle_theme_action("remove_logo", _params, socket) do
if logo = socket.assigns.theme_editor_logo_image do
Media.delete_image(logo)
end
{:ok, theme_settings} =
Settings.update_theme_settings(%{logo_image_id: nil, show_site_name: true})
generated_css = CSSGenerator.generate(theme_settings)
socket =
socket
|> assign(:theme_editor_logo_image, nil)
|> assign(:logo_image, nil)
|> assign(:theme_editor_settings, theme_settings)
|> assign(:generated_css, generated_css)
{:halt, socket}
end
defp handle_theme_action("remove_header", _params, socket) do
if header = socket.assigns.theme_editor_header_image do
Media.delete_image(header)
end
Settings.update_theme_settings(%{header_image_id: nil})
socket =
socket
|> assign(:theme_editor_header_image, nil)
|> assign(:header_image, nil)
|> assign(:theme_editor_contrast_warning, :ok)
{:halt, socket}
end
defp handle_theme_action("remove_icon", _params, socket) do
if icon = socket.assigns.theme_editor_icon_image do
Media.delete_image(icon)
end
Settings.update_theme_settings(%{icon_image_id: nil})
socket =
socket
|> assign(:theme_editor_icon_image, nil)
|> assign(:icon_image, nil)
{:halt, socket}
end
# Catch-all for unknown theme actions
defp handle_theme_action(_action, _params, socket), do: {:halt, socket}
# Helper to update a theme setting and regenerate CSS
defp update_theme_setting(socket, attrs, field) do
case Settings.update_theme_settings(attrs) do
{:ok, theme_settings} ->
generated_css =
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
active_preset = Presets.detect_preset(theme_settings)
socket =
socket
# Update editor state
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_editor_active_preset, active_preset)
|> maybe_recompute_contrast(field)
# Update shop state so layout reflects changes live
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
{:halt, socket}
{:error, _} ->
{:halt, socket}
end
end
defp maybe_recompute_contrast(socket, field)
when field in ["mood", "header_background_enabled"] do
header_image = socket.assigns.theme_editor_header_image
theme_settings = socket.assigns.theme_editor_settings
contrast_warning = compute_header_contrast(theme_settings, header_image)
assign(socket, :theme_editor_contrast_warning, contrast_warning)
end
defp maybe_recompute_contrast(socket, _field), do: socket
defp has_valid_logo?(socket) do
socket.assigns.theme_editor_settings.show_logo &&
socket.assigns.theme_editor_logo_image != nil
end
# ── Private helpers ──────────────────────────────────────────────
defp enter_edit_mode(socket) do
@@ -458,7 +734,6 @@ 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, [])
@@ -491,4 +766,61 @@ defmodule BerrypodWeb.PageEditorHook do
extra = Pages.load_block_data(blocks, socket.assigns)
Enum.reduce(extra, socket, fn {key, value}, acc -> assign(acc, key, value) end)
end
# ── Theme editing helpers ───────────────────────────────────────────
# Load theme state without changing tabs (for settings tab that needs theme data)
defp load_theme_state(socket) do
theme_settings = Settings.get_theme_settings()
generated_css =
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
active_preset = Presets.detect_preset(theme_settings)
logo_image = Media.get_logo()
header_image = Media.get_header()
icon_image = Media.get_icon()
contrast_warning = compute_header_contrast(theme_settings, header_image)
socket
|> assign(:theme_editing, true)
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_editor_active_preset, active_preset)
|> assign(:theme_editor_logo_image, logo_image)
|> assign(:theme_editor_header_image, header_image)
|> assign(:theme_editor_icon_image, icon_image)
|> assign(:theme_editor_contrast_warning, contrast_warning)
|> assign(:theme_editor_customise_open, false)
# Update both editor and shop state
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:editor_sheet_state, :open)
end
defp enter_theme_edit_mode(socket) do
socket
|> load_theme_state()
|> assign(:editor_active_tab, :theme)
end
defp exit_theme_edit_mode(socket) do
socket
|> assign(:theme_editing, false)
|> assign(:theme_editor_settings, nil)
|> assign(:theme_editor_active_preset, nil)
|> assign(:theme_editor_contrast_warning, :ok)
|> assign(:theme_editor_customise_open, false)
end
defp compute_header_contrast(theme_settings, header_image) do
if theme_settings.header_background_enabled && header_image do
text_color = Contrast.text_color_for_mood(theme_settings.mood)
colors = Contrast.parse_dominant_colors(header_image.dominant_colors)
Contrast.analyze_header_contrast(colors, text_color)
else
:ok
end
end
end

View File

@@ -81,14 +81,18 @@ defmodule BerrypodWeb.PageRenderer do
</main>
</.shop_layout>
<%!-- Editor sheet for page editing --%>
<%!-- Editor sheet for page/theme/settings editing --%>
<.editor_sheet
editing={@editing}
theme_editing={Map.get(assigns, :theme_editing, false)}
editor_dirty={@editor_dirty}
editor_sheet_state={assigns[:editor_sheet_state] || :collapsed}
editor_save_status={@editor_save_status}
editor_active_tab={Map.get(assigns, :editor_active_tab, :page)}
has_editable_page={@page != nil}
>
<.editor_sheet_content
<.editor_panel_content
editor_active_tab={Map.get(assigns, :editor_active_tab, :page)}
page={@page}
editing_blocks={@editing_blocks}
editor_history={@editor_history}
@@ -103,13 +107,247 @@ defmodule BerrypodWeb.PageRenderer do
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)}
theme_editor_settings={Map.get(assigns, :theme_editor_settings)}
theme_editor_active_preset={Map.get(assigns, :theme_editor_active_preset)}
theme_editor_presets={Map.get(assigns, :theme_editor_presets, [])}
theme_editor_customise_open={Map.get(assigns, :theme_editor_customise_open, false)}
site_name={Map.get(assigns, :site_name, "")}
/>
</.editor_sheet>
"""
end
# Editor panel content dispatcher - shows content based on active tab
attr :editor_active_tab, :atom, default: :page
attr :page, :map, default: nil
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
attr :theme_editor_settings, :map, default: nil
attr :theme_editor_active_preset, :atom, default: nil
attr :theme_editor_presets, :list, default: []
attr :theme_editor_customise_open, :boolean, default: false
attr :site_name, :string, default: ""
defp editor_panel_content(%{editor_active_tab: :page} = assigns) do
~H"""
<.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={@editor_at_defaults}
/>
"""
end
defp editor_panel_content(%{editor_active_tab: :theme} = assigns) do
~H"""
<.theme_editor_content
theme_editor_settings={@theme_editor_settings}
theme_editor_active_preset={@theme_editor_active_preset}
theme_editor_presets={@theme_editor_presets}
theme_editor_customise_open={@theme_editor_customise_open}
site_name={@site_name}
/>
"""
end
defp editor_panel_content(%{editor_active_tab: :settings} = assigns) do
~H"""
<.settings_editor_content page={@page} site_name={@site_name} />
"""
end
# Theme editor content - shows theme controls
attr :theme_editor_settings, :map, default: nil
attr :theme_editor_active_preset, :atom, default: nil
attr :theme_editor_presets, :list, default: []
attr :theme_editor_customise_open, :boolean, default: false
attr :site_name, :string, default: ""
defp theme_editor_content(assigns) do
~H"""
<div class="editor-theme-content">
<%= if @theme_editor_settings do %>
<%!-- Shop name --%>
<div class="theme-section">
<label class="theme-section-label">Shop name</label>
<form phx-change="theme_update_setting" phx-value-field="site_name">
<input
type="text"
name="site_name"
value={@site_name}
placeholder="Your shop name"
class="admin-input"
/>
</form>
</div>
<%!-- Presets --%>
<div class="theme-section">
<label class="theme-section-label">Preset</label>
<div class="theme-presets">
<%= for {preset_name, description} <- @theme_editor_presets do %>
<button
type="button"
phx-click="theme_apply_preset"
phx-value-preset={preset_name}
class={[
"theme-preset",
@theme_editor_active_preset == preset_name && "theme-preset-active"
]}
>
<div class="theme-preset-name">{preset_name}</div>
<div class="theme-preset-desc">{description}</div>
</button>
<% end %>
</div>
</div>
<%!-- Mood --%>
<div class="theme-section">
<label class="theme-section-label">Colour mood</label>
<div class="theme-chips">
<%= for mood <- ["warm", "neutral", "cool", "dark"] do %>
<button
type="button"
phx-click="theme_update_setting"
phx-value-field="mood"
phx-value-setting_value={mood}
class={["theme-chip", @theme_editor_settings.mood == mood && "theme-chip-active"]}
>
{mood}
</button>
<% end %>
</div>
</div>
<%!-- Typography --%>
<div class="theme-section">
<label class="theme-section-label">Font style</label>
<div class="theme-chips">
<%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal"] do %>
<button
type="button"
phx-click="theme_update_setting"
phx-value-field="typography"
phx-value-setting_value={typo}
class={[
"theme-chip",
@theme_editor_settings.typography == typo && "theme-chip-active"
]}
>
{typo}
</button>
<% end %>
</div>
</div>
<%!-- Shape --%>
<div class="theme-section">
<label class="theme-section-label">Corner style</label>
<div class="theme-chips">
<%= for shape <- ["sharp", "soft", "round", "pill"] do %>
<button
type="button"
phx-click="theme_update_setting"
phx-value-field="shape"
phx-value-setting_value={shape}
class={["theme-chip", @theme_editor_settings.shape == shape && "theme-chip-active"]}
>
{shape}
</button>
<% end %>
</div>
</div>
<%!-- More options link --%>
<details
class="theme-customise"
id="theme-customise-section"
open={@theme_editor_customise_open}
>
<summary class="theme-customise-summary" phx-click="theme_toggle_customise">
<span class="theme-customise-label">More options</span>
<svg
class="theme-customise-chevron"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</summary>
<div class="theme-customise-body">
<p class="admin-text-secondary">
For full theme customisation including branding, colours, and layout, <a
href="/admin/theme"
class="admin-link"
>visit the theme editor</a>.
</p>
</div>
</details>
<% else %>
<p class="admin-text-secondary">Loading theme settings...</p>
<% end %>
</div>
"""
end
# Settings editor content - shows page/shop settings
attr :page, :map, default: nil
attr :site_name, :string, default: ""
defp settings_editor_content(assigns) do
~H"""
<div class="editor-settings-content">
<%= if @page do %>
<div class="theme-section">
<label class="theme-section-label">Page</label>
<p class="admin-text-secondary">{@page.title}</p>
</div>
<div class="theme-section">
<p class="admin-text-secondary">
Page settings like SEO, visibility, and slug editing coming soon.
For now, <a href="/admin/pages" class="admin-link">manage pages in admin</a>.
</p>
</div>
<% else %>
<div class="theme-section">
<p class="admin-text-secondary">
This page doesn't have editable settings.
<a href="/admin/settings" class="admin-link">Shop settings</a>
can be changed in admin.
</p>
</div>
<% end %>
</div>
"""
end
# Editor sheet content - the block list and editing controls
attr :page, :map, required: true
attr :page, :map, default: nil
attr :editing_blocks, :list, default: nil
attr :editor_history, :list, default: []
attr :editor_future, :list, default: []