add Site context with social links editor and site-wide settings
Some checks failed
deploy / deploy (push) Has been cancelled

- Add Site context for managing site-wide content (social links, nav items,
  announcement bar, footer content)
- Add SocialLink schema with URL normalization and platform auto-detection
  supporting 40+ platforms via host and 25+ via URI scheme
- Add NavItem schema for header/footer navigation (editor UI coming next)
- Add SiteEditor component with collapsible sections for each content type
- Wire social links card block and footer to use database data
- Filter empty URLs from display in shop components
- Add DetailsPreserver hook to preserve collapsible section state
- Add comprehensive tests for Site context and SocialLink functions
- Remove unused helper functions from onboarding to fix compiler warnings
- Move sync_edit_url_param helper to group handle_editor_event clauses

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-03-28 10:09:33 +00:00
parent 0b86cd66ce
commit 638bb4fb70
24 changed files with 3121 additions and 195 deletions

View File

@@ -20,7 +20,7 @@ defmodule BerrypodWeb.PageEditorHook do
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [attach_hook: 4, push_navigate: 2, push_patch: 2]
alias Berrypod.{Media, Settings}
alias Berrypod.{Media, Settings, Site}
alias Berrypod.Pages
alias Berrypod.Pages.{BlockEditor, BlockTypes, Defaults}
alias Berrypod.Theme.{Contrast, CSSGenerator, Presets}
@@ -53,6 +53,8 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editor_active_tab, :page)
# Theme editing state
|> assign(:theme_editing, false)
|> assign(:theme_dirty, false)
|> assign(:theme_editor_original, nil)
|> assign(:theme_editor_settings, nil)
|> assign(:theme_editor_active_preset, nil)
|> assign(:theme_editor_logo_image, nil)
@@ -64,6 +66,21 @@ defmodule BerrypodWeb.PageEditorHook do
# Settings editing state
|> assign(:settings_dirty, false)
|> assign(:settings_save_status, :idle)
# 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)
|> 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)
@@ -119,6 +136,12 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:editor_active_tab, :settings)
|> maybe_enter_theme_mode()
"site" ->
socket
|> assign(:editor_sheet_state, :open)
|> assign(:editor_active_tab, :site)
|> maybe_enter_site_mode()
nil ->
# No edit param - collapse the editor (supports browser back)
assign(socket, :editor_sheet_state, :collapsed)
@@ -153,6 +176,14 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
defp maybe_enter_site_mode(socket) do
if socket.assigns.site_editing do
socket
else
load_site_state(socket)
end
end
# ── handle_info ─────────────────────────────────────────────────
defp handle_editor_info(:editor_clear_save_status, socket) do
@@ -192,18 +223,6 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
# 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
# Tab switching for unified editor
defp handle_editor_event("editor_set_tab", %{"tab" => tab_str}, socket) do
if socket.assigns.is_admin do
@@ -249,6 +268,26 @@ defmodule BerrypodWeb.PageEditorHook do
end
assign(socket, :editor_active_tab, :settings)
: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)
end
# Open the sheet and sync URL with new tab
@@ -276,6 +315,54 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
# 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
defp handle_editor_event("editor_" <> action, params, socket) do
if socket.assigns.editing do
handle_editor_action(action, params, socket)
@@ -302,8 +389,29 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
# 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
defp handle_editor_event(_event, _params, socket), do: {:cont, socket}
# 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
# ── Block manipulation actions ───────────────────────────────────
defp handle_editor_action("move_up", %{"id" => id}, socket) do
@@ -603,6 +711,11 @@ defmodule BerrypodWeb.PageEditorHook do
end
end
defp handle_editor_action("save_all", _params, socket) do
socket = save_all_tabs(socket)
{:halt, socket}
end
defp handle_editor_action("reset_defaults", _params, socket) do
slug = socket.assigns.page.slug
default_blocks = Berrypod.Pages.Defaults.for_slug(slug).blocks
@@ -627,8 +740,16 @@ defmodule BerrypodWeb.PageEditorHook do
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} ->
# 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)
generated_css =
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
@@ -641,14 +762,12 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_editor_active_preset, preset_atom)
|> assign(:theme_editor_contrast_warning, contrast_warning)
|> assign(:theme_dirty, true)
# 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
@@ -658,6 +777,8 @@ defmodule BerrypodWeb.PageEditorHook do
socket
)
when field in @standalone_settings do
# Standalone settings (site_name, site_description) save immediately for now
# TODO: Track these separately for proper revert
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)}
@@ -677,6 +798,8 @@ defmodule BerrypodWeb.PageEditorHook do
value = params[field]
if value do
# Standalone settings save immediately for now
# TODO: Track these separately for proper revert
Settings.put_setting(field, value, "string")
{:halt, assign(socket, String.to_existing_atom(field), value)}
else
@@ -719,13 +842,14 @@ defmodule BerrypodWeb.PageEditorHook do
end
defp handle_theme_action("remove_logo", _params, socket) do
# Delete the image immediately (this is a destructive action)
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})
# Update settings in memory only
current = socket.assigns.theme_editor_settings
theme_settings = %{current | logo_image_id: nil, show_site_name: true}
generated_css = CSSGenerator.generate(theme_settings)
socket =
@@ -733,38 +857,51 @@ defmodule BerrypodWeb.PageEditorHook do
|> assign(:theme_editor_logo_image, nil)
|> assign(:logo_image, nil)
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_dirty, true)
|> assign(:generated_css, generated_css)
{:halt, socket}
end
defp handle_theme_action("remove_header", _params, socket) do
# Delete the image immediately (this is a destructive action)
if header = socket.assigns.theme_editor_header_image do
Media.delete_image(header)
end
Settings.update_theme_settings(%{header_image_id: nil})
# Update settings in memory only
current = socket.assigns.theme_editor_settings
theme_settings = %{current | header_image_id: nil}
generated_css = CSSGenerator.generate(theme_settings)
socket =
socket
|> assign(:theme_editor_header_image, nil)
|> assign(:header_image, nil)
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_dirty, true)
|> assign(:theme_editor_contrast_warning, :ok)
|> assign(:generated_css, generated_css)
{:halt, socket}
end
defp handle_theme_action("remove_icon", _params, socket) do
# Delete the image immediately (this is a destructive action)
if icon = socket.assigns.theme_editor_icon_image do
Media.delete_image(icon)
end
Settings.update_theme_settings(%{icon_image_id: nil})
# Update settings in memory only
current = socket.assigns.theme_editor_settings
theme_settings = %{current | icon_image_id: nil}
socket =
socket
|> assign(:theme_editor_icon_image, nil)
|> assign(:icon_image, nil)
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_dirty, true)
{:halt, socket}
end
@@ -853,6 +990,222 @@ defmodule BerrypodWeb.PageEditorHook do
# Catch-all for unknown settings actions
defp handle_settings_action(_action, _params, socket), do: {:halt, socket}
# --- 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
# Check if settings have changed from current page values
defp has_settings_changed?(page, params) do
page.title != (params["title"] || "") or
@@ -881,30 +1234,34 @@ defmodule BerrypodWeb.PageEditorHook do
assign(socket, :settings_form, form)
end
# Helper to update a theme setting and regenerate CSS
# Helper to update a theme setting in-memory (preview only, no persistence)
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)
current = socket.assigns.theme_editor_settings
active_preset = Presets.detect_preset(theme_settings)
# 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)
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)
generated_css =
CSSGenerator.generate(theme_settings, &BerrypodWeb.Endpoint.static_path/1)
{:halt, socket}
active_preset = Presets.detect_preset(theme_settings)
{:error, _} ->
{:halt, socket}
end
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}
end
defp maybe_recompute_contrast(socket, field)
@@ -1017,6 +1374,8 @@ defmodule BerrypodWeb.PageEditorHook do
socket
|> assign(:theme_editing, true)
|> assign(:theme_dirty, false)
|> assign(:theme_editor_original, theme_settings)
|> assign(:theme_editor_settings, theme_settings)
|> assign(:theme_editor_active_preset, active_preset)
|> assign(:theme_editor_logo_image, logo_image)
@@ -1054,4 +1413,189 @@ defmodule BerrypodWeb.PageEditorHook do
:ok
end
end
# ── 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)
end