2026-02-27 16:22:35 +00:00
|
|
|
defmodule BerrypodWeb.PageEditorHook do
|
|
|
|
|
@moduledoc """
|
2026-03-09 09:01:21 +00:00
|
|
|
LiveView on_mount hook for the unified on-site editor.
|
2026-02-27 16:22:35 +00:00
|
|
|
|
|
|
|
|
Mounted in the public_shop live_session. When an admin visits any shop
|
2026-03-09 09:01:21 +00:00
|
|
|
page, this hook enables editing capabilities:
|
2026-02-27 16:22:35 +00:00
|
|
|
|
2026-03-09 09:01:21 +00:00
|
|
|
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.
|
2026-02-27 16:22:35 +00:00
|
|
|
Non-admin users are unaffected — the hook just assigns `editing: false`.
|
|
|
|
|
|
|
|
|
|
## Actions
|
|
|
|
|
|
|
|
|
|
- `:mount_page_editor` — sets up editing assigns and attaches hooks
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import Phoenix.Component, only: [assign: 3]
|
2026-03-09 21:59:48 +00:00
|
|
|
import Phoenix.LiveView, only: [attach_hook: 4, push_navigate: 2, push_patch: 2]
|
2026-02-27 16:22:35 +00:00
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
alias Berrypod.{Media, Settings, Site}
|
2026-02-27 16:22:35 +00:00
|
|
|
alias Berrypod.Pages
|
2026-03-07 09:30:07 +00:00
|
|
|
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
|
2026-03-09 09:01:21 +00:00
|
|
|
alias Berrypod.Theme.{Contrast, CSSGenerator, Presets}
|
2026-02-27 16:22:35 +00:00
|
|
|
|
|
|
|
|
def on_mount(:mount_page_editor, _params, _session, socket) do
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
2026-03-09 09:01:21 +00:00
|
|
|
# Page editing state
|
2026-02-27 16:22:35 +00:00
|
|
|
|> assign(:editing, false)
|
|
|
|
|
|> assign(:editing_blocks, nil)
|
2026-03-09 14:47:50 +00:00
|
|
|
|> assign(:editor_page_slug, nil)
|
2026-02-27 16:22:35 +00:00
|
|
|
|> assign(:editor_dirty, false)
|
2026-03-07 09:30:07 +00:00
|
|
|
|> assign(:editor_at_defaults, true)
|
2026-02-28 12:16:15 +00:00
|
|
|
|> assign(:editor_history, [])
|
|
|
|
|
|> assign(:editor_future, [])
|
2026-02-27 16:22:35 +00:00
|
|
|
|> assign(:editor_expanded, MapSet.new())
|
|
|
|
|
|> assign(:editor_show_picker, false)
|
|
|
|
|
|> assign(:editor_picker_filter, "")
|
|
|
|
|
|> assign(:editor_allowed_blocks, nil)
|
|
|
|
|
|> assign(:editor_live_region_message, nil)
|
|
|
|
|
|> assign(:editor_current_path, nil)
|
|
|
|
|
|> assign(:editor_sidebar_open, true)
|
2026-03-07 09:30:07 +00:00
|
|
|
|> assign(:editor_sheet_state, :collapsed)
|
2026-02-28 01:00:48 +00:00
|
|
|
|> assign(:editor_image_picker_block_id, nil)
|
|
|
|
|
|> assign(:editor_image_picker_field_key, nil)
|
|
|
|
|
|> assign(:editor_image_picker_images, [])
|
|
|
|
|
|> assign(:editor_image_picker_search, "")
|
2026-03-07 09:30:07 +00:00
|
|
|
|> assign(:editor_save_status, :idle)
|
2026-03-09 09:01:21 +00:00
|
|
|
# Unified editor tab state (:page | :theme | :settings)
|
|
|
|
|
|> assign(:editor_active_tab, :page)
|
|
|
|
|
# Theme editing state
|
|
|
|
|
|> assign(:theme_editing, false)
|
2026-03-28 10:09:33 +00:00
|
|
|
|> assign(:theme_dirty, false)
|
|
|
|
|
|> assign(:theme_editor_original, nil)
|
2026-03-09 09:01:21 +00:00
|
|
|
|> 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())
|
2026-03-09 15:38:06 +00:00
|
|
|
# Settings editing state
|
|
|
|
|
|> assign(:settings_dirty, false)
|
|
|
|
|
|> assign(:settings_save_status, :idle)
|
2026-03-28 10:09:33 +00:00
|
|
|
# Site editing state
|
|
|
|
|
|> assign(:site_editing, false)
|
|
|
|
|
|> assign(:site_dirty, false)
|
|
|
|
|
|> assign(:site_editor_original, nil)
|
|
|
|
|
|> assign(:site_header_nav, [])
|
|
|
|
|
|> assign(:site_footer_nav, [])
|
|
|
|
|
|> assign(:site_social_links, [])
|
|
|
|
|
|> assign(:site_announcement_text, "")
|
|
|
|
|
|> assign(:site_announcement_link, "")
|
|
|
|
|
|> assign(:site_announcement_style, "info")
|
|
|
|
|
|> assign(:site_footer_about, "")
|
|
|
|
|
|> assign(:site_footer_copyright, "")
|
|
|
|
|
|> assign(:site_footer_show_newsletter, true)
|
|
|
|
|
# Navigation warning state
|
|
|
|
|
|> assign(:editor_nav_blocked, nil)
|
2026-02-27 16:22:35 +00:00
|
|
|
|> 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)
|
|
|
|
|
|
|
|
|
|
{:cont, socket}
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-09 14:47:50 +00:00
|
|
|
# ── handle_params: track current path and restore editor state ────
|
2026-02-27 16:22:35 +00:00
|
|
|
|
2026-03-09 14:47:50 +00:00
|
|
|
defp handle_editor_params(params, uri, socket) do
|
2026-02-27 16:22:35 +00:00
|
|
|
parsed = URI.parse(uri)
|
|
|
|
|
|
2026-03-09 14:47:50 +00:00
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editor_current_path, parsed.path)
|
|
|
|
|
|> maybe_restore_editor_state(params)
|
|
|
|
|
|
|
|
|
|
{:cont, socket}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Restore editor state from URL params on navigation
|
|
|
|
|
# Only activates state if not already in the requested state (avoids loops)
|
|
|
|
|
defp maybe_restore_editor_state(socket, params) do
|
|
|
|
|
if socket.assigns.is_admin do
|
|
|
|
|
requested_tab = params["edit"]
|
|
|
|
|
current_tab = socket.assigns.editor_active_tab
|
|
|
|
|
current_state = socket.assigns.editor_sheet_state
|
|
|
|
|
|
|
|
|
|
# If already in the correct state, don't re-apply
|
|
|
|
|
already_correct? =
|
|
|
|
|
current_state == :open && requested_tab &&
|
|
|
|
|
String.to_existing_atom(requested_tab) == current_tab
|
|
|
|
|
|
|
|
|
|
if already_correct? do
|
|
|
|
|
socket
|
|
|
|
|
else
|
|
|
|
|
case requested_tab do
|
|
|
|
|
"theme" ->
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editor_sheet_state, :open)
|
|
|
|
|
|> assign(:editor_active_tab, :theme)
|
|
|
|
|
|> maybe_enter_theme_mode()
|
|
|
|
|
|
|
|
|
|
"page" ->
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editor_sheet_state, :open)
|
|
|
|
|
|> assign(:editor_active_tab, :page)
|
|
|
|
|
|> maybe_enter_page_mode()
|
|
|
|
|
|
|
|
|
|
"settings" ->
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editor_sheet_state, :open)
|
|
|
|
|
|> assign(:editor_active_tab, :settings)
|
|
|
|
|
|> maybe_enter_theme_mode()
|
|
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
"site" ->
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editor_sheet_state, :open)
|
|
|
|
|
|> assign(:editor_active_tab, :site)
|
|
|
|
|
|> maybe_enter_site_mode()
|
|
|
|
|
|
2026-03-09 21:59:48 +00:00
|
|
|
nil ->
|
|
|
|
|
# No edit param - collapse the editor (supports browser back)
|
|
|
|
|
assign(socket, :editor_sheet_state, :collapsed)
|
|
|
|
|
|
2026-03-09 14:47:50 +00:00
|
|
|
_ ->
|
2026-03-09 21:59:48 +00:00
|
|
|
# Unknown edit value - ignore
|
2026-03-09 14:47:50 +00:00
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
else
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp maybe_enter_theme_mode(socket) do
|
|
|
|
|
if socket.assigns.theme_editing do
|
|
|
|
|
socket
|
|
|
|
|
else
|
|
|
|
|
load_theme_state(socket)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp maybe_enter_page_mode(socket) do
|
|
|
|
|
if socket.assigns.editing do
|
|
|
|
|
socket
|
|
|
|
|
else
|
|
|
|
|
if socket.assigns[:page] do
|
|
|
|
|
enter_edit_mode(socket)
|
|
|
|
|
else
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
end
|
2026-03-07 09:30:07 +00:00
|
|
|
end
|
2026-02-27 16:22:35 +00:00
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
defp maybe_enter_site_mode(socket) do
|
|
|
|
|
if socket.assigns.site_editing do
|
|
|
|
|
socket
|
|
|
|
|
else
|
|
|
|
|
load_site_state(socket)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-07 09:30:07 +00:00
|
|
|
# ── handle_info ─────────────────────────────────────────────────
|
2026-02-27 16:22:35 +00:00
|
|
|
|
2026-03-07 09:30:07 +00:00
|
|
|
defp handle_editor_info(:editor_clear_save_status, socket) do
|
|
|
|
|
{:halt, assign(socket, :editor_save_status, :idle)}
|
|
|
|
|
end
|
2026-02-27 16:22:35 +00:00
|
|
|
|
2026-03-07 09:30:07 +00:00
|
|
|
defp handle_editor_info(_msg, socket), do: {:cont, socket}
|
|
|
|
|
|
|
|
|
|
# ── handle_event: editor_* events ────────────────────────────────
|
2026-02-27 16:22:35 +00:00
|
|
|
|
2026-03-07 09:30:07 +00:00
|
|
|
# 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}
|
2026-02-27 16:22:35 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-07 09:30:07 +00:00
|
|
|
# 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
|
2026-03-09 09:01:21 +00:00
|
|
|
if socket.assigns.is_admin do
|
2026-03-07 09:30:07 +00:00
|
|
|
state = if state_str == "open", do: :open, else: :collapsed
|
2026-03-09 21:59:48 +00:00
|
|
|
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editor_sheet_state, state)
|
|
|
|
|
|> sync_edit_url_param(state)
|
|
|
|
|
|
|
|
|
|
{:halt, socket}
|
2026-02-27 16:22:35 +00:00
|
|
|
else
|
|
|
|
|
{:cont, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-09 09:01:21 +00:00
|
|
|
# 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
|
|
|
|
|
|
2026-03-09 15:38:06 +00:00
|
|
|
# Initialize settings form from current page if not already set
|
|
|
|
|
# Only custom pages have editable settings (meta_description, published, etc.)
|
|
|
|
|
socket =
|
|
|
|
|
if is_nil(socket.assigns[:settings_form]) && socket.assigns[:page] &&
|
|
|
|
|
socket.assigns.page[:type] == "custom" do
|
|
|
|
|
init_settings_form(socket)
|
|
|
|
|
else
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-09 09:01:21 +00:00
|
|
|
assign(socket, :editor_active_tab, :settings)
|
2026-03-28 10:09:33 +00:00
|
|
|
|
|
|
|
|
:site ->
|
|
|
|
|
# Site tab shows site-wide content editors
|
|
|
|
|
# Load theme state for branding settings (will be moved here from theme tab)
|
|
|
|
|
socket =
|
|
|
|
|
if socket.assigns.theme_editing do
|
|
|
|
|
socket
|
|
|
|
|
else
|
|
|
|
|
load_theme_state(socket)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Load site state if not already loaded
|
|
|
|
|
socket =
|
|
|
|
|
if socket.assigns.site_editing do
|
|
|
|
|
socket
|
|
|
|
|
else
|
|
|
|
|
load_site_state(socket)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
assign(socket, :editor_active_tab, :site)
|
2026-03-09 09:01:21 +00:00
|
|
|
end
|
|
|
|
|
|
2026-03-09 21:59:48 +00:00
|
|
|
# Open the sheet and sync URL with new tab
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editor_sheet_state, :open)
|
|
|
|
|
|> sync_edit_url_param(:open)
|
|
|
|
|
|
2026-03-09 09:01:21 +00:00
|
|
|
{: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
|
|
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
# Unified save works for all tabs regardless of editing mode
|
|
|
|
|
defp handle_editor_event("editor_save_all", _params, socket) do
|
|
|
|
|
if socket.assigns.is_admin do
|
|
|
|
|
socket = save_all_tabs(socket)
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
else
|
|
|
|
|
{:cont, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Navigation blocked by unsaved changes
|
|
|
|
|
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
|
|
|
|
|
href = socket.assigns.editor_nav_blocked
|
|
|
|
|
socket = save_all_tabs(socket)
|
|
|
|
|
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editor_nav_blocked, nil)
|
|
|
|
|
|> Phoenix.LiveView.push_event("editor_navigate", %{href: href})
|
|
|
|
|
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_event("editor_discard_and_navigate", _params, socket) do
|
|
|
|
|
href = socket.assigns.editor_nav_blocked
|
|
|
|
|
socket = revert_all_tabs(socket)
|
|
|
|
|
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editor_nav_blocked, nil)
|
|
|
|
|
|> Phoenix.LiveView.push_event("editor_navigate", %{href: href})
|
|
|
|
|
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_event("editor_cancel_navigate", _params, socket) do
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editor_nav_blocked, nil)
|
|
|
|
|
|> assign(:editor_sheet_state, :open)
|
|
|
|
|
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
defp handle_editor_event("editor_" <> action, params, socket) do
|
|
|
|
|
if socket.assigns.editing do
|
|
|
|
|
handle_editor_action(action, params, socket)
|
|
|
|
|
else
|
|
|
|
|
{:cont, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-09 09:01:21 +00:00
|
|
|
# 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
|
|
|
|
|
|
2026-03-09 15:38:06 +00:00
|
|
|
# Settings editing events (custom page settings)
|
|
|
|
|
defp handle_editor_event("settings_" <> action, params, socket) do
|
|
|
|
|
if socket.assigns.is_admin do
|
|
|
|
|
handle_settings_action(action, params, socket)
|
|
|
|
|
else
|
|
|
|
|
{:cont, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
# Site editing events (announcement bar, footer, etc.)
|
|
|
|
|
defp handle_editor_event("site_" <> action, params, socket) do
|
|
|
|
|
if socket.assigns.is_admin do
|
|
|
|
|
handle_site_action(action, params, socket)
|
|
|
|
|
else
|
|
|
|
|
{:cont, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
defp handle_editor_event(_event, _params, socket), do: {:cont, socket}
|
|
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
# Sync URL ?edit param with editor state
|
|
|
|
|
defp sync_edit_url_param(socket, :collapsed) do
|
|
|
|
|
path = socket.assigns.editor_current_path || "/"
|
|
|
|
|
push_patch(socket, to: path)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp sync_edit_url_param(socket, :open) do
|
|
|
|
|
path = socket.assigns.editor_current_path || "/"
|
|
|
|
|
tab = socket.assigns.editor_active_tab
|
|
|
|
|
push_patch(socket, to: "#{path}?edit=#{tab}")
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
# ── Block manipulation actions ───────────────────────────────────
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("move_up", %{"id" => id}, socket) do
|
|
|
|
|
case BlockEditor.move_up(socket.assigns.editing_blocks, id) do
|
|
|
|
|
{:ok, new_blocks, message} ->
|
|
|
|
|
{:halt, apply_mutation(socket, new_blocks, message, :reorder)}
|
|
|
|
|
|
|
|
|
|
:noop ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("move_down", %{"id" => id}, socket) do
|
|
|
|
|
case BlockEditor.move_down(socket.assigns.editing_blocks, id) do
|
|
|
|
|
{:ok, new_blocks, message} ->
|
|
|
|
|
{:halt, apply_mutation(socket, new_blocks, message, :reorder)}
|
|
|
|
|
|
|
|
|
|
:noop ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("remove_block", %{"id" => id}, socket) do
|
|
|
|
|
{:ok, new_blocks, message} = BlockEditor.remove_block(socket.assigns.editing_blocks, id)
|
|
|
|
|
{:halt, apply_mutation(socket, new_blocks, message, :content)}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("duplicate_block", %{"id" => id}, socket) do
|
|
|
|
|
case BlockEditor.duplicate_block(socket.assigns.editing_blocks, id) do
|
|
|
|
|
{:ok, new_blocks, message} ->
|
|
|
|
|
{:halt, apply_mutation(socket, new_blocks, message, :content)}
|
|
|
|
|
|
|
|
|
|
:noop ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("add_block", %{"type" => type}, socket) do
|
|
|
|
|
case BlockEditor.add_block(socket.assigns.editing_blocks, type) do
|
|
|
|
|
{:ok, new_blocks, message} ->
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
2026-02-28 12:16:15 +00:00
|
|
|
|> apply_mutation(new_blocks, message, :content)
|
2026-02-27 16:22:35 +00:00
|
|
|
|> assign(:editor_show_picker, false)
|
|
|
|
|
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
|
|
|
|
|
:noop ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("update_block_settings", params, socket) do
|
|
|
|
|
block_id = params["block_id"]
|
|
|
|
|
new_settings = params["block_settings"] || %{}
|
|
|
|
|
|
|
|
|
|
case BlockEditor.update_settings(socket.assigns.editing_blocks, block_id, new_settings) do
|
|
|
|
|
{:ok, new_blocks} ->
|
2026-02-28 12:16:15 +00:00
|
|
|
{:halt, apply_mutation(socket, new_blocks, "Settings updated", :content)}
|
2026-02-27 16:22:35 +00:00
|
|
|
|
|
|
|
|
:noop ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# ── Repeater actions ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action(
|
|
|
|
|
"repeater_add",
|
|
|
|
|
%{"block-id" => block_id, "field" => field_key},
|
|
|
|
|
socket
|
|
|
|
|
) do
|
|
|
|
|
case BlockEditor.repeater_add(socket.assigns.editing_blocks, block_id, field_key) do
|
|
|
|
|
{:ok, new_blocks, message} ->
|
|
|
|
|
{:halt, apply_mutation(socket, new_blocks, message, :content)}
|
|
|
|
|
|
|
|
|
|
:noop ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action(
|
|
|
|
|
"repeater_remove",
|
|
|
|
|
%{"block-id" => block_id, "field" => field_key, "index" => index_str},
|
|
|
|
|
socket
|
|
|
|
|
) do
|
|
|
|
|
index = String.to_integer(index_str)
|
|
|
|
|
|
|
|
|
|
case BlockEditor.repeater_remove(socket.assigns.editing_blocks, block_id, field_key, index) do
|
|
|
|
|
{:ok, new_blocks, message} ->
|
|
|
|
|
{:halt, apply_mutation(socket, new_blocks, message, :content)}
|
|
|
|
|
|
|
|
|
|
:noop ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action(
|
|
|
|
|
"repeater_move",
|
|
|
|
|
%{"block-id" => block_id, "field" => field_key, "index" => index_str, "dir" => dir},
|
|
|
|
|
socket
|
|
|
|
|
) do
|
|
|
|
|
index = String.to_integer(index_str)
|
|
|
|
|
|
|
|
|
|
case BlockEditor.repeater_move(
|
|
|
|
|
socket.assigns.editing_blocks,
|
|
|
|
|
block_id,
|
|
|
|
|
field_key,
|
|
|
|
|
index,
|
|
|
|
|
dir
|
|
|
|
|
) do
|
|
|
|
|
{:ok, new_blocks, message} ->
|
|
|
|
|
{:halt, apply_mutation(socket, new_blocks, message, :reorder)}
|
|
|
|
|
|
|
|
|
|
:noop ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# ── UI state actions ─────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("toggle_expand", %{"id" => block_id}, socket) do
|
|
|
|
|
expanded = socket.assigns.editor_expanded
|
|
|
|
|
block = Enum.find(socket.assigns.editing_blocks, &(&1["id"] == block_id))
|
|
|
|
|
block_name = BlockEditor.block_display_name(block)
|
|
|
|
|
|
|
|
|
|
{new_expanded, action} =
|
|
|
|
|
if MapSet.member?(expanded, block_id) do
|
|
|
|
|
{MapSet.delete(expanded, block_id), "collapsed"}
|
|
|
|
|
else
|
|
|
|
|
{MapSet.put(expanded, block_id), "expanded"}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
{:halt,
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editor_expanded, new_expanded)
|
|
|
|
|
|> assign(:editor_live_region_message, "#{block_name} settings #{action}")}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("toggle_sidebar", _params, socket) do
|
|
|
|
|
{:halt, assign(socket, :editor_sidebar_open, !socket.assigns.editor_sidebar_open)}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("show_picker", _params, socket) do
|
|
|
|
|
{:halt,
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editor_show_picker, true)
|
|
|
|
|
|> assign(:editor_picker_filter, "")}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("hide_picker", _params, socket) do
|
|
|
|
|
{:halt, assign(socket, :editor_show_picker, false)}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("filter_picker", %{"value" => value}, socket) do
|
|
|
|
|
{:halt, assign(socket, :editor_picker_filter, value)}
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-28 01:00:48 +00:00
|
|
|
# ── Image picker actions ───────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action(
|
|
|
|
|
"show_image_picker",
|
|
|
|
|
%{"block-id" => block_id, "field" => field_key},
|
|
|
|
|
socket
|
|
|
|
|
) do
|
|
|
|
|
images = Berrypod.Media.list_images()
|
|
|
|
|
|
|
|
|
|
{:halt,
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editor_image_picker_block_id, block_id)
|
|
|
|
|
|> assign(:editor_image_picker_field_key, field_key)
|
|
|
|
|
|> assign(:editor_image_picker_images, images)
|
|
|
|
|
|> assign(:editor_image_picker_search, "")}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("hide_image_picker", _params, socket) do
|
|
|
|
|
{:halt,
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editor_image_picker_block_id, nil)
|
|
|
|
|
|> assign(:editor_image_picker_field_key, nil)}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("image_picker_search", %{"value" => value}, socket) do
|
|
|
|
|
{:halt, assign(socket, :editor_image_picker_search, value)}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("pick_image", %{"image-id" => image_id}, socket) do
|
|
|
|
|
block_id = socket.assigns.editor_image_picker_block_id
|
|
|
|
|
field_key = socket.assigns.editor_image_picker_field_key
|
|
|
|
|
|
|
|
|
|
case BlockEditor.update_settings(socket.assigns.editing_blocks, block_id, %{
|
|
|
|
|
field_key => image_id
|
|
|
|
|
}) do
|
|
|
|
|
{:ok, new_blocks} ->
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
2026-02-28 12:16:15 +00:00
|
|
|
|> apply_mutation(new_blocks, "Image selected", :content)
|
2026-02-28 01:00:48 +00:00
|
|
|
|> assign(:editor_image_picker_block_id, nil)
|
|
|
|
|
|> assign(:editor_image_picker_field_key, nil)
|
|
|
|
|
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
|
|
|
|
|
:noop ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action(
|
|
|
|
|
"clear_image",
|
|
|
|
|
%{"block-id" => block_id, "field" => field_key},
|
|
|
|
|
socket
|
|
|
|
|
) do
|
|
|
|
|
case BlockEditor.update_settings(socket.assigns.editing_blocks, block_id, %{
|
|
|
|
|
field_key => ""
|
|
|
|
|
}) do
|
|
|
|
|
{:ok, new_blocks} ->
|
2026-02-28 12:16:15 +00:00
|
|
|
{:halt, apply_mutation(socket, new_blocks, "Image cleared", :content)}
|
|
|
|
|
|
|
|
|
|
:noop ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# ── Undo / redo ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("undo", _params, socket) do
|
|
|
|
|
case socket.assigns.editor_history do
|
|
|
|
|
[prev | rest] ->
|
|
|
|
|
future = [socket.assigns.editing_blocks | socket.assigns.editor_future]
|
2026-03-07 09:30:07 +00:00
|
|
|
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, prev)
|
2026-02-28 12:16:15 +00:00
|
|
|
|
2026-02-28 01:00:48 +00:00
|
|
|
socket =
|
|
|
|
|
socket
|
2026-02-28 12:16:15 +00:00
|
|
|
|> assign(:editing_blocks, prev)
|
|
|
|
|
|> assign(:editor_history, rest)
|
|
|
|
|
|> assign(:editor_future, future)
|
2026-02-28 01:00:48 +00:00
|
|
|
|> assign(:editor_dirty, true)
|
2026-03-07 09:30:07 +00:00
|
|
|
|> assign(:editor_at_defaults, at_defaults)
|
2026-02-28 12:16:15 +00:00
|
|
|
|> assign(:editor_live_region_message, "Undone")
|
|
|
|
|
|> reload_block_data(prev)
|
2026-02-28 01:00:48 +00:00
|
|
|
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
|
2026-02-28 12:16:15 +00:00
|
|
|
[] ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("redo", _params, socket) do
|
|
|
|
|
case socket.assigns.editor_future do
|
|
|
|
|
[next | rest] ->
|
|
|
|
|
history = [socket.assigns.editing_blocks | socket.assigns.editor_history]
|
2026-03-07 09:30:07 +00:00
|
|
|
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, next)
|
2026-02-28 12:16:15 +00:00
|
|
|
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editing_blocks, next)
|
|
|
|
|
|> assign(:editor_history, history)
|
|
|
|
|
|> assign(:editor_future, rest)
|
|
|
|
|
|> assign(:editor_dirty, true)
|
2026-03-07 09:30:07 +00:00
|
|
|
|> assign(:editor_at_defaults, at_defaults)
|
2026-02-28 12:16:15 +00:00
|
|
|
|> assign(:editor_live_region_message, "Redone")
|
|
|
|
|
|> reload_block_data(next)
|
|
|
|
|
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
|
|
|
|
|
[] ->
|
2026-02-28 01:00:48 +00:00
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
# ── Page actions ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("save", _params, socket) do
|
|
|
|
|
%{page: page, editing_blocks: blocks} = socket.assigns
|
|
|
|
|
|
|
|
|
|
case Pages.save_page(page.slug, %{title: page.title, blocks: blocks}) do
|
|
|
|
|
{:ok, _saved_page} ->
|
|
|
|
|
updated_page = Pages.get_page(page.slug)
|
2026-03-07 09:30:07 +00:00
|
|
|
at_defaults = Defaults.matches_defaults?(page.slug, updated_page.blocks)
|
|
|
|
|
Process.send_after(self(), :editor_clear_save_status, 2500)
|
2026-02-27 16:22:35 +00:00
|
|
|
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:page, updated_page)
|
|
|
|
|
|> assign(:editing_blocks, updated_page.blocks)
|
|
|
|
|
|> assign(:editor_dirty, false)
|
2026-03-07 09:30:07 +00:00
|
|
|
|> assign(:editor_at_defaults, at_defaults)
|
2026-02-28 12:16:15 +00:00
|
|
|
|> assign(:editor_history, [])
|
|
|
|
|
|> assign(:editor_future, [])
|
2026-03-07 09:30:07 +00:00
|
|
|
|> assign(:editor_save_status, :saved)
|
2026-02-27 16:22:35 +00:00
|
|
|
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
|
|
|
|
|
{:error, _changeset} ->
|
2026-03-07 09:30:07 +00:00
|
|
|
{:halt, assign(socket, :editor_save_status, :error)}
|
2026-02-27 16:22:35 +00:00
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
defp handle_editor_action("save_all", _params, socket) do
|
|
|
|
|
socket = save_all_tabs(socket)
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
defp handle_editor_action("reset_defaults", _params, socket) do
|
|
|
|
|
slug = socket.assigns.page.slug
|
2026-03-07 09:30:07 +00:00
|
|
|
default_blocks = Berrypod.Pages.Defaults.for_slug(slug).blocks
|
2026-02-27 16:22:35 +00:00
|
|
|
|
2026-03-07 09:30:07 +00:00
|
|
|
# Treat reset like any other mutation: push to history, mark dirty
|
|
|
|
|
{:halt, apply_mutation(socket, default_blocks, "Reset to defaults", :content)}
|
2026-02-27 16:22:35 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_editor_action("done", _params, socket) do
|
|
|
|
|
path = socket.assigns.editor_current_path || "/"
|
|
|
|
|
{:halt, push_navigate(socket, to: path)}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Catch-all for unknown editor actions
|
|
|
|
|
defp handle_editor_action(_action, _params, socket), do: {:halt, socket}
|
|
|
|
|
|
2026-03-09 09:01:21 +00:00
|
|
|
# ── 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)
|
|
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
# Get preset values and apply in-memory (don't persist yet)
|
|
|
|
|
case Presets.get(preset_atom) do
|
|
|
|
|
nil ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
|
|
|
|
|
preset_values ->
|
|
|
|
|
# Merge preset values into current settings
|
|
|
|
|
current = socket.assigns.theme_editor_settings
|
|
|
|
|
theme_settings = struct(current, preset_values)
|
|
|
|
|
|
2026-03-09 09:01:21 +00:00
|
|
|
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)
|
2026-03-28 10:09:33 +00:00
|
|
|
|> assign(:theme_dirty, true)
|
2026-03-09 09:01:21 +00:00
|
|
|
# Update shop state so layout reflects changes live
|
|
|
|
|
|> assign(:theme_settings, theme_settings)
|
|
|
|
|
|> assign(:generated_css, generated_css)
|
|
|
|
|
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_theme_action(
|
|
|
|
|
"update_setting",
|
|
|
|
|
%{"field" => field, "setting_value" => value},
|
|
|
|
|
socket
|
|
|
|
|
)
|
|
|
|
|
when field in @standalone_settings do
|
2026-03-28 10:09:33 +00:00
|
|
|
# Standalone settings (site_name, site_description) save immediately for now
|
|
|
|
|
# TODO: Track these separately for proper revert
|
2026-03-09 09:01:21 +00:00
|
|
|
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
|
2026-03-28 10:09:33 +00:00
|
|
|
# Standalone settings save immediately for now
|
|
|
|
|
# TODO: Track these separately for proper revert
|
2026-03-09 09:01:21 +00:00
|
|
|
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
|
2026-03-28 10:09:33 +00:00
|
|
|
# Delete the image immediately (this is a destructive action)
|
2026-03-09 09:01:21 +00:00
|
|
|
if logo = socket.assigns.theme_editor_logo_image do
|
|
|
|
|
Media.delete_image(logo)
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
# Update settings in memory only
|
|
|
|
|
current = socket.assigns.theme_editor_settings
|
|
|
|
|
theme_settings = %{current | logo_image_id: nil, show_site_name: true}
|
2026-03-09 09:01:21 +00:00
|
|
|
generated_css = CSSGenerator.generate(theme_settings)
|
|
|
|
|
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:theme_editor_logo_image, nil)
|
|
|
|
|
|> assign(:logo_image, nil)
|
|
|
|
|
|> assign(:theme_editor_settings, theme_settings)
|
2026-03-28 10:09:33 +00:00
|
|
|
|> assign(:theme_dirty, true)
|
2026-03-09 09:01:21 +00:00
|
|
|
|> assign(:generated_css, generated_css)
|
|
|
|
|
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_theme_action("remove_header", _params, socket) do
|
2026-03-28 10:09:33 +00:00
|
|
|
# Delete the image immediately (this is a destructive action)
|
2026-03-09 09:01:21 +00:00
|
|
|
if header = socket.assigns.theme_editor_header_image do
|
|
|
|
|
Media.delete_image(header)
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
# Update settings in memory only
|
|
|
|
|
current = socket.assigns.theme_editor_settings
|
|
|
|
|
theme_settings = %{current | header_image_id: nil}
|
|
|
|
|
generated_css = CSSGenerator.generate(theme_settings)
|
2026-03-09 09:01:21 +00:00
|
|
|
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:theme_editor_header_image, nil)
|
|
|
|
|
|> assign(:header_image, nil)
|
2026-03-28 10:09:33 +00:00
|
|
|
|> assign(:theme_editor_settings, theme_settings)
|
|
|
|
|
|> assign(:theme_dirty, true)
|
2026-03-09 09:01:21 +00:00
|
|
|
|> assign(:theme_editor_contrast_warning, :ok)
|
2026-03-28 10:09:33 +00:00
|
|
|
|> assign(:generated_css, generated_css)
|
2026-03-09 09:01:21 +00:00
|
|
|
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_theme_action("remove_icon", _params, socket) do
|
2026-03-28 10:09:33 +00:00
|
|
|
# Delete the image immediately (this is a destructive action)
|
2026-03-09 09:01:21 +00:00
|
|
|
if icon = socket.assigns.theme_editor_icon_image do
|
|
|
|
|
Media.delete_image(icon)
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
# Update settings in memory only
|
|
|
|
|
current = socket.assigns.theme_editor_settings
|
|
|
|
|
theme_settings = %{current | icon_image_id: nil}
|
2026-03-09 09:01:21 +00:00
|
|
|
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:theme_editor_icon_image, nil)
|
|
|
|
|
|> assign(:icon_image, nil)
|
2026-03-28 10:09:33 +00:00
|
|
|
|> assign(:theme_editor_settings, theme_settings)
|
|
|
|
|
|> assign(:theme_dirty, true)
|
2026-03-09 09:01:21 +00:00
|
|
|
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-09 19:45:43 +00:00
|
|
|
defp handle_theme_action(
|
|
|
|
|
"cancel_upload",
|
|
|
|
|
%{"ref" => ref, "upload" => upload_name},
|
|
|
|
|
socket
|
|
|
|
|
) do
|
|
|
|
|
upload_atom = String.to_existing_atom(upload_name)
|
|
|
|
|
{:halt, Phoenix.LiveView.cancel_upload(socket, upload_atom, ref)}
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-09 09:01:21 +00:00
|
|
|
# Catch-all for unknown theme actions
|
|
|
|
|
defp handle_theme_action(_action, _params, socket), do: {:halt, socket}
|
|
|
|
|
|
2026-03-09 15:38:06 +00:00
|
|
|
# ── Settings editing actions (custom page settings) ────────────────
|
|
|
|
|
|
|
|
|
|
# Validate page settings form (live as-you-type)
|
|
|
|
|
defp handle_settings_action("validate_page", %{"page" => params}, socket) do
|
|
|
|
|
page = socket.assigns.page
|
|
|
|
|
|
|
|
|
|
# Only allow editing custom pages
|
|
|
|
|
if page && page.type == "custom" do
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:settings_form, params)
|
|
|
|
|
|> assign(:settings_dirty, has_settings_changed?(page, params))
|
|
|
|
|
|> assign(:settings_save_status, :idle)
|
|
|
|
|
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
else
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Save page settings
|
|
|
|
|
defp handle_settings_action("save_page", %{"page" => params}, socket) do
|
|
|
|
|
page = socket.assigns.page
|
|
|
|
|
|
|
|
|
|
# Only allow editing custom pages
|
|
|
|
|
if page && page.type == "custom" do
|
|
|
|
|
# Normalize checkbox fields (unchecked checkboxes aren't sent)
|
|
|
|
|
params =
|
|
|
|
|
params
|
|
|
|
|
|> Map.put_new("published", "false")
|
|
|
|
|
|> Map.put_new("show_in_nav", "false")
|
|
|
|
|
|
|
|
|
|
old_slug = page.slug
|
|
|
|
|
|
|
|
|
|
# Fetch the Page struct from DB (assigns.page may be a map from cache)
|
|
|
|
|
page_struct = Pages.get_page_struct(page.slug)
|
|
|
|
|
|
|
|
|
|
case Pages.update_custom_page(page_struct, params) do
|
|
|
|
|
{:ok, updated_page} ->
|
|
|
|
|
# Reinitialize form from saved page
|
|
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:page, updated_page)
|
|
|
|
|
|> assign(:settings_form, nil)
|
|
|
|
|
|> assign(:settings_dirty, false)
|
|
|
|
|
|> assign(:settings_save_status, :saved)
|
|
|
|
|
|
|
|
|
|
# Reinit form with new page values
|
|
|
|
|
socket = init_settings_form(socket)
|
|
|
|
|
|
|
|
|
|
# If slug changed, redirect to new URL
|
|
|
|
|
socket =
|
|
|
|
|
if updated_page.slug != old_slug do
|
|
|
|
|
push_navigate(socket, to: "/#{updated_page.slug}")
|
|
|
|
|
else
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
|
|
|
|
|
{:error, _changeset} ->
|
|
|
|
|
socket = assign(socket, :settings_save_status, :error)
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
else
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Catch-all for unknown settings actions
|
|
|
|
|
defp handle_settings_action(_action, _params, socket), do: {:halt, socket}
|
|
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
# --- Site tab event handlers ---
|
|
|
|
|
|
|
|
|
|
defp handle_site_action("update", %{"site" => site_params}, socket) do
|
|
|
|
|
socket = handle_site_update(socket, site_params)
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_site_action("update", _params, socket), do: {:halt, socket}
|
|
|
|
|
|
|
|
|
|
# Social link CRUD operations (persist immediately like images)
|
|
|
|
|
defp handle_site_action("add_social_link", _params, socket) do
|
|
|
|
|
# Create with "custom" platform and blank URL
|
|
|
|
|
# User will paste their link, which auto-detects the platform
|
|
|
|
|
position = length(socket.assigns.site_social_links)
|
|
|
|
|
|
|
|
|
|
attrs = %{
|
|
|
|
|
platform: "custom",
|
|
|
|
|
url: "",
|
|
|
|
|
position: position
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case Site.create_social_link(attrs) do
|
|
|
|
|
{:ok, link} ->
|
|
|
|
|
links = socket.assigns.site_social_links ++ [link]
|
|
|
|
|
{:halt, assign(socket, :site_social_links, links)}
|
|
|
|
|
|
|
|
|
|
{:error, _changeset} ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_site_action("update_social_link", %{"id" => id} = params, socket) do
|
|
|
|
|
link = Enum.find(socket.assigns.site_social_links, &(&1.id == id))
|
|
|
|
|
|
|
|
|
|
if link do
|
|
|
|
|
# Extract the nested params from the form field name
|
|
|
|
|
link_params = params["social_link"][id] || %{}
|
|
|
|
|
target = params["_target"] || []
|
|
|
|
|
|
|
|
|
|
# Check what field was changed
|
|
|
|
|
url_changed? = List.last(target) == "url"
|
|
|
|
|
platform_changed? = List.last(target) == "platform"
|
|
|
|
|
|
|
|
|
|
# Build attrs based on what changed
|
|
|
|
|
attrs =
|
|
|
|
|
cond do
|
|
|
|
|
# URL changed - normalize and update URL, maybe auto-detect platform
|
|
|
|
|
url_changed? ->
|
|
|
|
|
url = link_params["url"] |> Berrypod.Site.SocialLink.normalize_url()
|
|
|
|
|
detected = Berrypod.Site.SocialLink.detect_platform(url)
|
|
|
|
|
|
|
|
|
|
base = %{url: url}
|
|
|
|
|
|
|
|
|
|
# Auto-detect platform when:
|
|
|
|
|
# 1. Current platform is "custom" (initial state), OR
|
|
|
|
|
# 2. New URL detects to a different platform than currently set
|
|
|
|
|
# (e.g., changing from github.com to twitter.com)
|
|
|
|
|
should_update_platform? =
|
|
|
|
|
detected &&
|
|
|
|
|
detected != "custom" &&
|
|
|
|
|
(link.platform == "custom" || detected != link.platform)
|
|
|
|
|
|
|
|
|
|
if should_update_platform? do
|
|
|
|
|
Map.put(base, :platform, detected)
|
|
|
|
|
else
|
|
|
|
|
base
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Platform explicitly changed by user - use their selection
|
|
|
|
|
platform_changed? ->
|
|
|
|
|
%{platform: link_params["platform"]}
|
|
|
|
|
|
|
|
|
|
# Fallback
|
|
|
|
|
true ->
|
|
|
|
|
%{}
|
|
|
|
|
|> maybe_put(:url, link_params["url"])
|
|
|
|
|
|> maybe_put(:platform, link_params["platform"])
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
if attrs != %{} do
|
|
|
|
|
case Site.update_social_link(link, attrs) do
|
|
|
|
|
{:ok, updated_link} ->
|
|
|
|
|
links =
|
|
|
|
|
Enum.map(socket.assigns.site_social_links, fn l ->
|
|
|
|
|
if l.id == id, do: updated_link, else: l
|
|
|
|
|
end)
|
|
|
|
|
|
|
|
|
|
{:halt, assign(socket, :site_social_links, links)}
|
|
|
|
|
|
|
|
|
|
{:error, _changeset} ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
else
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
else
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_site_action("remove_social_link", %{"id" => id}, socket) do
|
|
|
|
|
link = Enum.find(socket.assigns.site_social_links, &(&1.id == id))
|
|
|
|
|
|
|
|
|
|
if link do
|
|
|
|
|
case Site.delete_social_link(link) do
|
|
|
|
|
{:ok, _} ->
|
|
|
|
|
links = Enum.reject(socket.assigns.site_social_links, &(&1.id == id))
|
|
|
|
|
{:halt, assign(socket, :site_social_links, links)}
|
|
|
|
|
|
|
|
|
|
{:error, _} ->
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
else
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp handle_site_action("move_social_link", %{"id" => id, "dir" => dir}, socket) do
|
|
|
|
|
links = socket.assigns.site_social_links
|
|
|
|
|
index = Enum.find_index(links, &(&1.id == id))
|
|
|
|
|
|
|
|
|
|
new_index =
|
|
|
|
|
case dir do
|
|
|
|
|
"up" -> max(0, index - 1)
|
|
|
|
|
"down" -> min(length(links) - 1, index + 1)
|
|
|
|
|
_ -> index
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
if index != new_index do
|
|
|
|
|
# Swap the items
|
|
|
|
|
item = Enum.at(links, index)
|
|
|
|
|
other = Enum.at(links, new_index)
|
|
|
|
|
|
|
|
|
|
reordered =
|
|
|
|
|
links
|
|
|
|
|
|> List.replace_at(index, other)
|
|
|
|
|
|> List.replace_at(new_index, item)
|
|
|
|
|
|
|
|
|
|
# Persist the new order
|
|
|
|
|
ids = Enum.map(reordered, & &1.id)
|
|
|
|
|
Site.reorder_social_links(ids)
|
|
|
|
|
|
|
|
|
|
{:halt, assign(socket, :site_social_links, reordered)}
|
|
|
|
|
else
|
|
|
|
|
{:halt, socket}
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Catch-all for unknown site actions
|
|
|
|
|
defp handle_site_action(_action, _params, socket), do: {:halt, socket}
|
|
|
|
|
|
|
|
|
|
defp handle_site_update(socket, params) do
|
|
|
|
|
# Handle announcement bar fields (preview only, no persistence)
|
|
|
|
|
socket =
|
|
|
|
|
if Map.has_key?(params, "announcement_text") or
|
|
|
|
|
Map.has_key?(params, "announcement_link") or
|
|
|
|
|
Map.has_key?(params, "announcement_style") do
|
|
|
|
|
text = params["announcement_text"] || socket.assigns.site_announcement_text
|
|
|
|
|
link = params["announcement_link"] || socket.assigns.site_announcement_link
|
|
|
|
|
style = params["announcement_style"] || socket.assigns.site_announcement_style
|
|
|
|
|
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:site_announcement_text, text)
|
|
|
|
|
|> assign(:site_announcement_link, link)
|
|
|
|
|
|> assign(:site_announcement_style, style)
|
|
|
|
|
else
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Handle footer fields (preview only, no persistence)
|
|
|
|
|
socket =
|
|
|
|
|
if Map.has_key?(params, "footer_about") or
|
|
|
|
|
Map.has_key?(params, "footer_copyright") or
|
|
|
|
|
Map.has_key?(params, "show_newsletter") do
|
|
|
|
|
about = params["footer_about"] || socket.assigns.site_footer_about
|
|
|
|
|
copyright = params["footer_copyright"] || socket.assigns.site_footer_copyright
|
|
|
|
|
|
|
|
|
|
# Checkbox sends value when checked, absent when unchecked
|
|
|
|
|
show_newsletter =
|
|
|
|
|
if Map.has_key?(params, "show_newsletter") do
|
|
|
|
|
params["show_newsletter"] == "true"
|
|
|
|
|
else
|
|
|
|
|
false
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:site_footer_about, about)
|
|
|
|
|
|> assign(:site_footer_copyright, copyright)
|
|
|
|
|
|> assign(:site_footer_show_newsletter, show_newsletter)
|
|
|
|
|
else
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Mark as dirty if values differ from original
|
|
|
|
|
socket
|
|
|
|
|
|> compute_site_dirty()
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp compute_site_dirty(socket) do
|
|
|
|
|
original = socket.assigns[:site_editor_original]
|
|
|
|
|
|
|
|
|
|
dirty =
|
|
|
|
|
if original do
|
|
|
|
|
socket.assigns.site_announcement_text != original.announcement_text or
|
|
|
|
|
socket.assigns.site_announcement_link != original.announcement_link or
|
|
|
|
|
socket.assigns.site_announcement_style != original.announcement_style or
|
|
|
|
|
socket.assigns.site_footer_about != original.footer_about or
|
|
|
|
|
socket.assigns.site_footer_copyright != original.footer_copyright or
|
|
|
|
|
socket.assigns.site_footer_show_newsletter != original.show_newsletter
|
|
|
|
|
else
|
|
|
|
|
false
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
assign(socket, :site_dirty, dirty)
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-09 15:38:06 +00:00
|
|
|
# Check if settings have changed from current page values
|
|
|
|
|
defp has_settings_changed?(page, params) do
|
|
|
|
|
page.title != (params["title"] || "") or
|
|
|
|
|
page.slug != (params["slug"] || "") or
|
|
|
|
|
(page.meta_description || "") != (params["meta_description"] || "") or
|
|
|
|
|
to_string(page.published) != (params["published"] || "false") or
|
|
|
|
|
to_string(page.show_in_nav) != (params["show_in_nav"] || "false") or
|
|
|
|
|
(page.nav_label || "") != (params["nav_label"] || "") or
|
|
|
|
|
to_string(page.nav_position || 0) != (params["nav_position"] || "0")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# Initialize settings form from page values
|
|
|
|
|
defp init_settings_form(socket) do
|
|
|
|
|
page = socket.assigns.page
|
|
|
|
|
|
|
|
|
|
form = %{
|
|
|
|
|
"title" => page.title || "",
|
|
|
|
|
"slug" => page.slug || "",
|
|
|
|
|
"meta_description" => page.meta_description || "",
|
|
|
|
|
"published" => to_string(page.published),
|
|
|
|
|
"show_in_nav" => to_string(page.show_in_nav),
|
|
|
|
|
"nav_label" => page.nav_label || "",
|
|
|
|
|
"nav_position" => to_string(page.nav_position || 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
assign(socket, :settings_form, form)
|
|
|
|
|
end
|
|
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
# Helper to update a theme setting in-memory (preview only, no persistence)
|
2026-03-09 09:01:21 +00:00
|
|
|
defp update_theme_setting(socket, attrs, field) do
|
2026-03-28 10:09:33 +00:00
|
|
|
current = socket.assigns.theme_editor_settings
|
2026-03-09 09:01:21 +00:00
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
# Merge attrs into current settings (convert string keys to atoms)
|
|
|
|
|
theme_settings =
|
|
|
|
|
Enum.reduce(attrs, current, fn {key, value}, acc ->
|
|
|
|
|
atom_key = if is_binary(key), do: String.to_existing_atom(key), else: key
|
|
|
|
|
Map.put(acc, atom_key, value)
|
|
|
|
|
end)
|
2026-03-09 09:01:21 +00:00
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
generated_css =
|
|
|
|
|
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
|
2026-03-09 09:01:21 +00:00
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
active_preset = Presets.detect_preset(theme_settings)
|
2026-03-09 09:01:21 +00:00
|
|
|
|
2026-03-28 10:09:33 +00:00
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
# Update editor state
|
|
|
|
|
|> assign(:theme_editor_settings, theme_settings)
|
|
|
|
|
|> assign(:theme_editor_active_preset, active_preset)
|
|
|
|
|
|> assign(:theme_dirty, true)
|
|
|
|
|
|> maybe_recompute_contrast(field)
|
|
|
|
|
# Update shop state so layout reflects changes live
|
|
|
|
|
|> assign(:theme_settings, theme_settings)
|
|
|
|
|
|> assign(:generated_css, generated_css)
|
|
|
|
|
|
|
|
|
|
{:halt, socket}
|
2026-03-09 09:01:21 +00:00
|
|
|
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
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
# ── Private helpers ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
defp enter_edit_mode(socket) do
|
|
|
|
|
page = socket.assigns.page
|
|
|
|
|
allowed = BlockTypes.allowed_for(page.slug)
|
2026-03-07 09:30:07 +00:00
|
|
|
at_defaults = Defaults.matches_defaults?(page.slug, page.blocks)
|
2026-02-27 16:22:35 +00:00
|
|
|
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editing, true)
|
|
|
|
|
|> assign(:editing_blocks, page.blocks)
|
2026-03-09 14:47:50 +00:00
|
|
|
|> assign(:editor_page_slug, page.slug)
|
2026-02-27 16:22:35 +00:00
|
|
|
|> assign(:editor_dirty, false)
|
2026-03-07 09:30:07 +00:00
|
|
|
|> assign(:editor_at_defaults, at_defaults)
|
2026-02-28 12:16:15 +00:00
|
|
|
|> assign(:editor_history, [])
|
|
|
|
|
|> assign(:editor_future, [])
|
2026-02-27 16:22:35 +00:00
|
|
|
|> assign(:editor_expanded, MapSet.new())
|
|
|
|
|
|> assign(:editor_show_picker, false)
|
|
|
|
|
|> assign(:editor_picker_filter, "")
|
|
|
|
|
|> assign(:editor_allowed_blocks, allowed)
|
|
|
|
|
|> assign(:editor_live_region_message, nil)
|
|
|
|
|
|> assign(:editor_sidebar_open, true)
|
2026-03-07 09:30:07 +00:00
|
|
|
|> assign(:editor_sheet_state, :open)
|
2026-02-28 01:00:48 +00:00
|
|
|
|> assign(:editor_image_picker_block_id, nil)
|
|
|
|
|
|> assign(:editor_image_picker_field_key, nil)
|
|
|
|
|
|> assign(:editor_image_picker_images, [])
|
|
|
|
|
|> assign(:editor_image_picker_search, "")
|
2026-03-07 09:30:07 +00:00
|
|
|
|> assign(:editor_save_status, :idle)
|
2026-02-27 16:22:35 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp exit_edit_mode(socket) do
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editing, false)
|
|
|
|
|
|> assign(:editing_blocks, nil)
|
2026-03-09 14:47:50 +00:00
|
|
|
|> assign(:editor_page_slug, nil)
|
2026-02-27 16:22:35 +00:00
|
|
|
|> assign(:editor_dirty, false)
|
2026-02-28 12:16:15 +00:00
|
|
|
|> assign(:editor_history, [])
|
|
|
|
|
|> assign(:editor_future, [])
|
2026-02-27 16:22:35 +00:00
|
|
|
|> assign(:editor_expanded, MapSet.new())
|
|
|
|
|
|> assign(:editor_show_picker, false)
|
|
|
|
|
|> assign(:editor_picker_filter, "")
|
|
|
|
|
|> assign(:editor_allowed_blocks, nil)
|
|
|
|
|
|> assign(:editor_live_region_message, nil)
|
|
|
|
|
|> assign(:editor_sidebar_open, true)
|
2026-02-28 01:00:48 +00:00
|
|
|
|> assign(:editor_image_picker_block_id, nil)
|
|
|
|
|
|> assign(:editor_image_picker_field_key, nil)
|
|
|
|
|
|> assign(:editor_image_picker_images, [])
|
|
|
|
|
|> assign(:editor_image_picker_search, "")
|
2026-03-07 09:30:07 +00:00
|
|
|
|> assign(:editor_save_status, :idle)
|
2026-02-27 16:22:35 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp apply_mutation(socket, new_blocks, message, type) do
|
2026-02-28 12:16:15 +00:00
|
|
|
history =
|
|
|
|
|
[socket.assigns.editing_blocks | socket.assigns.editor_history] |> Enum.take(50)
|
|
|
|
|
|
2026-03-07 09:30:07 +00:00
|
|
|
at_defaults = Defaults.matches_defaults?(socket.assigns.page.slug, new_blocks)
|
|
|
|
|
|
2026-02-27 16:22:35 +00:00
|
|
|
socket =
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:editing_blocks, new_blocks)
|
2026-02-28 12:16:15 +00:00
|
|
|
|> assign(:editor_history, history)
|
|
|
|
|
|> assign(:editor_future, [])
|
2026-02-27 16:22:35 +00:00
|
|
|
|> assign(:editor_dirty, true)
|
2026-03-07 09:30:07 +00:00
|
|
|
|> assign(:editor_at_defaults, at_defaults)
|
2026-02-27 16:22:35 +00:00
|
|
|
|> assign(:editor_live_region_message, message)
|
|
|
|
|
|
|
|
|
|
case type do
|
|
|
|
|
:content -> reload_block_data(socket, new_blocks)
|
|
|
|
|
:reorder -> socket
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp reload_block_data(socket, blocks) do
|
|
|
|
|
extra = Pages.load_block_data(blocks, socket.assigns)
|
|
|
|
|
Enum.reduce(extra, socket, fn {key, value}, acc -> assign(acc, key, value) end)
|
|
|
|
|
end
|
2026-03-09 09:01:21 +00:00
|
|
|
|
|
|
|
|
# ── 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)
|
2026-03-28 10:09:33 +00:00
|
|
|
|> assign(:theme_dirty, false)
|
|
|
|
|
|> assign(:theme_editor_original, theme_settings)
|
2026-03-09 09:01:21 +00:00
|
|
|
|> 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
|
2026-03-28 10:09:33 +00:00
|
|
|
|
|
|
|
|
# ── Site editing helpers ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
defp load_site_state(socket) do
|
|
|
|
|
site_settings = Site.get_settings()
|
|
|
|
|
|
|
|
|
|
# Store original values for revert capability
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:site_editing, true)
|
|
|
|
|
|> assign(:site_dirty, false)
|
|
|
|
|
|> assign(:site_editor_original, original)
|
|
|
|
|
|> assign(:site_header_nav, Site.list_nav_items(:header))
|
|
|
|
|
|> assign(:site_footer_nav, Site.list_nav_items(:footer))
|
|
|
|
|
|> assign(:site_social_links, Site.list_social_links())
|
|
|
|
|
|> assign(:site_announcement_text, site_settings.announcement_text)
|
|
|
|
|
|> assign(:site_announcement_link, site_settings.announcement_link)
|
|
|
|
|
|> assign(:site_announcement_style, site_settings.announcement_style)
|
|
|
|
|
|> assign(:site_footer_about, site_settings.footer_about)
|
|
|
|
|
|> assign(:site_footer_copyright, site_settings.footer_copyright)
|
|
|
|
|
|> assign(:site_footer_show_newsletter, site_settings.show_newsletter)
|
|
|
|
|
|> assign(:editor_sheet_state, :open)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# ── Unified save helpers ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
defp save_all_tabs(socket) do
|
|
|
|
|
socket
|
|
|
|
|
|> maybe_save_page()
|
|
|
|
|
|> maybe_save_theme()
|
|
|
|
|
|> maybe_save_site()
|
|
|
|
|
|> assign(:editor_save_status, :saved)
|
|
|
|
|
|> schedule_save_status_clear()
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp maybe_save_page(socket) do
|
|
|
|
|
if socket.assigns[:editor_dirty] do
|
|
|
|
|
%{page: page, editing_blocks: blocks} = socket.assigns
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
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, [])
|
|
|
|
|
|
|
|
|
|
{:error, _changeset} ->
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
else
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp maybe_save_theme(socket) do
|
|
|
|
|
if socket.assigns[:theme_dirty] do
|
|
|
|
|
case Settings.update_theme_settings(Map.from_struct(socket.assigns.theme_editor_settings)) do
|
|
|
|
|
{:ok, theme_settings} ->
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:theme_editor_original, theme_settings)
|
|
|
|
|
|> assign(:theme_dirty, false)
|
|
|
|
|
|
|
|
|
|
{:error, _} ->
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
else
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp maybe_save_site(socket) do
|
|
|
|
|
if socket.assigns[:site_dirty] do
|
|
|
|
|
Site.put_announcement(
|
|
|
|
|
socket.assigns.site_announcement_text,
|
|
|
|
|
socket.assigns.site_announcement_link,
|
|
|
|
|
socket.assigns.site_announcement_style
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
Site.put_footer_content(
|
|
|
|
|
socket.assigns.site_footer_about,
|
|
|
|
|
socket.assigns.site_footer_copyright,
|
|
|
|
|
socket.assigns.site_footer_show_newsletter
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Update original to match saved values
|
|
|
|
|
original = %{
|
|
|
|
|
announcement_text: socket.assigns.site_announcement_text,
|
|
|
|
|
announcement_link: socket.assigns.site_announcement_link,
|
|
|
|
|
announcement_style: socket.assigns.site_announcement_style,
|
|
|
|
|
footer_about: socket.assigns.site_footer_about,
|
|
|
|
|
footer_copyright: socket.assigns.site_footer_copyright,
|
|
|
|
|
show_newsletter: socket.assigns.site_footer_show_newsletter
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:site_editor_original, original)
|
|
|
|
|
|> assign(:site_dirty, false)
|
|
|
|
|
else
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp schedule_save_status_clear(socket) do
|
|
|
|
|
Process.send_after(self(), :editor_clear_save_status, 2500)
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# ── Revert helpers ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
defp revert_all_tabs(socket) do
|
|
|
|
|
socket
|
|
|
|
|
|> maybe_revert_page()
|
|
|
|
|
|> maybe_revert_theme()
|
|
|
|
|
|> maybe_revert_site()
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp maybe_revert_page(socket) do
|
|
|
|
|
if socket.assigns[:editor_dirty] do
|
|
|
|
|
# Reload the page from database
|
|
|
|
|
page = Pages.get_page(socket.assigns.page.slug)
|
|
|
|
|
at_defaults = Defaults.matches_defaults?(page.slug, page.blocks)
|
|
|
|
|
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:page, page)
|
|
|
|
|
|> assign(:editing_blocks, page.blocks)
|
|
|
|
|
|> assign(:editor_dirty, false)
|
|
|
|
|
|> assign(:editor_at_defaults, at_defaults)
|
|
|
|
|
|> assign(:editor_history, [])
|
|
|
|
|
|> assign(:editor_future, [])
|
|
|
|
|
else
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp maybe_revert_theme(socket) do
|
|
|
|
|
if socket.assigns[:theme_dirty] do
|
|
|
|
|
original = socket.assigns.theme_editor_original
|
|
|
|
|
generated_css = CSSGenerator.generate(original, &BerrypodWeb.Endpoint.static_path/1)
|
|
|
|
|
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:theme_editor_settings, original)
|
|
|
|
|
|> assign(:theme_settings, original)
|
|
|
|
|
|> assign(:generated_css, generated_css)
|
|
|
|
|
|> assign(:theme_dirty, false)
|
|
|
|
|
else
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
defp maybe_revert_site(socket) do
|
|
|
|
|
if socket.assigns[:site_dirty] do
|
|
|
|
|
original = socket.assigns.site_editor_original
|
|
|
|
|
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:site_announcement_text, original.announcement_text)
|
|
|
|
|
|> assign(:site_announcement_link, original.announcement_link)
|
|
|
|
|
|> assign(:site_announcement_style, original.announcement_style)
|
|
|
|
|
|> assign(:site_footer_about, original.footer_about)
|
|
|
|
|
|> assign(:site_footer_copyright, original.footer_copyright)
|
|
|
|
|
|> assign(:site_footer_show_newsletter, original.show_newsletter)
|
|
|
|
|
|> assign(:site_dirty, false)
|
|
|
|
|
else
|
|
|
|
|
socket
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
# ── Small helpers ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
defp maybe_put(map, _key, nil), do: map
|
|
|
|
|
defp maybe_put(map, _key, ""), do: map
|
|
|
|
|
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
2026-02-27 16:22:35 +00:00
|
|
|
end
|