berrypod/lib/berrypod_web/site_editor_state.ex
jamey 7c07805df8
All checks were successful
deploy / deploy (push) Successful in 3m27s
add nav editors to Site tab with live preview
- Add header and footer nav editors to Site tab with drag-to-reorder,
  add/remove items, and destination picker (pages, collections, external)
- Live preview updates as you edit nav items
- Remove legacy /admin/navigation page and controller (was saving to
  Settings table, now uses nav_items table)
- Update error_html.ex and pages/editor.ex to load nav from nav_items table
- Update link_scanner to read from nav_items table, edit path now /?edit=site
- Add Site.default_header_nav/0 and default_footer_nav/0 for previews/errors
- Remove fallback logic from theme_hook.ex (database is now source of truth)
- Seed default nav items and social links during setup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-28 22:19:48 +00:00

400 lines
12 KiB
Elixir

defmodule BerrypodWeb.SiteEditorState do
@moduledoc """
Encapsulates Site tab editor state with automatic dirty detection.
All Site tab state lives in a single struct. Comparing current state against
original state automatically determines if there are unsaved changes.
## Usage
# Load initial state
state = SiteEditorState.load()
# Update a field (returns {new_state, dirty?})
{state, dirty?} = SiteEditorState.put(state, :announcement_text, "New text")
# Update a list item
{state, dirty?} = SiteEditorState.update_social_link(state, id, %{url: "..."})
# Check if dirty
SiteEditorState.dirty?(state)
# Save to database
state = SiteEditorState.save(state)
# Revert to original
state = SiteEditorState.revert(state)
"""
alias Berrypod.Site
alias Berrypod.Site.{NavItem, SocialLink}
defstruct [
# Simple fields (Settings table)
:announcement_text,
:announcement_link,
:announcement_style,
:footer_about,
:footer_copyright,
:show_newsletter,
# List fields (separate tables)
:header_nav,
:footer_nav,
:social_links,
# Original state for dirty detection and revert
:_original
]
@type t :: %__MODULE__{
announcement_text: String.t(),
announcement_link: String.t(),
announcement_style: String.t(),
footer_about: String.t(),
footer_copyright: String.t(),
show_newsletter: boolean(),
header_nav: [NavItem.t()],
footer_nav: [NavItem.t()],
social_links: [SocialLink.t()],
_original: map() | nil
}
@doc """
Load Site editor state from database.
"""
@spec load() :: t()
def load do
settings = Site.get_settings()
header_nav = Site.list_nav_items(:header)
footer_nav = Site.list_nav_items(:footer)
social_links = Site.list_social_links()
state = %__MODULE__{
announcement_text: settings.announcement_text,
announcement_link: settings.announcement_link,
announcement_style: settings.announcement_style,
footer_about: settings.footer_about,
footer_copyright: settings.footer_copyright,
show_newsletter: settings.show_newsletter,
header_nav: header_nav,
footer_nav: footer_nav,
social_links: social_links
}
# Store snapshot for dirty detection
%{state | _original: snapshot(state)}
end
@doc """
Check if state has unsaved changes.
"""
@spec dirty?(t()) :: boolean()
def dirty?(%__MODULE__{_original: nil}), do: false
def dirty?(%__MODULE__{} = state) do
snapshot(state) != state._original
end
@doc """
Update a simple field. Returns updated state.
"""
@spec put(t(), atom(), any()) :: t()
def put(%__MODULE__{} = state, field, value) when is_atom(field) do
Map.put(state, field, value)
end
@doc """
Add a new social link. Returns updated state.
"""
@spec add_social_link(t()) :: t()
def add_social_link(%__MODULE__{social_links: links} = state) do
new_link = %SocialLink{
id: Ecto.UUID.generate(),
platform: "custom",
url: "",
position: length(links)
}
%{state | social_links: links ++ [new_link]}
end
@doc """
Update a social link by ID. Returns updated state.
"""
@spec update_social_link(t(), String.t(), map()) :: t()
def update_social_link(%__MODULE__{social_links: links} = state, id, attrs) do
updated_links =
Enum.map(links, fn link ->
if link.id == id, do: struct(link, attrs), else: link
end)
%{state | social_links: updated_links}
end
@doc """
Remove a social link by ID. Returns updated state.
"""
@spec remove_social_link(t(), String.t()) :: t()
def remove_social_link(%__MODULE__{social_links: links} = state, id) do
%{state | social_links: Enum.reject(links, &(&1.id == id))}
end
@doc """
Move a social link up or down. Returns updated state.
"""
@spec move_social_link(t(), String.t(), :up | :down) :: t()
def move_social_link(%__MODULE__{social_links: links} = state, id, direction) do
index = Enum.find_index(links, &(&1.id == id))
new_index =
case direction do
:up -> max(0, index - 1)
:down -> min(length(links) - 1, index + 1)
end
if index != new_index do
item = Enum.at(links, index)
other = Enum.at(links, new_index)
reordered =
links
|> List.replace_at(index, other)
|> List.replace_at(new_index, item)
%{state | social_links: reordered}
else
state
end
end
@doc """
Add a new nav item for the given location. Returns updated state.
"""
@spec add_nav_item(t(), String.t()) :: t()
def add_nav_item(%__MODULE__{} = state, location) do
items = get_nav_items(state, location)
new_item = %NavItem{
id: Ecto.UUID.generate(),
location: location,
label: "New link",
url: "",
position: length(items)
}
put_nav_items(state, location, items ++ [new_item])
end
@doc """
Update a nav item by ID. Returns updated state.
"""
@spec update_nav_item(t(), String.t(), String.t(), map()) :: t()
def update_nav_item(%__MODULE__{} = state, location, id, attrs) do
items = get_nav_items(state, location)
updated_items =
Enum.map(items, fn item ->
if item.id == id, do: struct(item, attrs), else: item
end)
put_nav_items(state, location, updated_items)
end
@doc """
Remove a nav item by ID. Returns updated state.
"""
@spec remove_nav_item(t(), String.t(), String.t()) :: t()
def remove_nav_item(%__MODULE__{} = state, location, id) do
items = get_nav_items(state, location)
put_nav_items(state, location, Enum.reject(items, &(&1.id == id)))
end
@doc """
Move a nav item up or down. Returns updated state.
"""
@spec move_nav_item(t(), String.t(), String.t(), :up | :down) :: t()
def move_nav_item(%__MODULE__{} = state, location, id, direction) do
items = get_nav_items(state, location)
index = Enum.find_index(items, &(&1.id == id))
new_index =
case direction do
:up -> max(0, index - 1)
:down -> min(length(items) - 1, index + 1)
end
if index != new_index do
item = Enum.at(items, index)
other = Enum.at(items, new_index)
reordered =
items
|> List.replace_at(index, other)
|> List.replace_at(new_index, item)
put_nav_items(state, location, reordered)
else
state
end
end
@doc """
Save all changes to the database. Returns updated state with new original.
"""
@spec save(t()) :: t()
def save(%__MODULE__{} = state) do
# Save simple settings
Site.put_announcement(
state.announcement_text,
state.announcement_link,
state.announcement_style
)
Site.put_footer_content(
state.footer_about,
state.footer_copyright,
state.show_newsletter
)
# Sync nav items and social links
header_nav = sync_nav_items(state.header_nav, state._original.header_nav)
footer_nav = sync_nav_items(state.footer_nav, state._original.footer_nav)
social_links = sync_social_links(state.social_links, state._original.social_links)
# Update state with database-assigned IDs and new original
state = %{state | header_nav: header_nav, footer_nav: footer_nav, social_links: social_links}
%{state | _original: snapshot(state)}
end
@doc """
Revert to original state. Returns reset state.
"""
@spec revert(t()) :: t()
def revert(%__MODULE__{_original: original} = state) when not is_nil(original) do
%{
state
| announcement_text: original.announcement_text,
announcement_link: original.announcement_link,
announcement_style: original.announcement_style,
footer_about: original.footer_about,
footer_copyright: original.footer_copyright,
show_newsletter: original.show_newsletter,
header_nav: original.header_nav,
footer_nav: original.footer_nav,
social_links: original.social_links
}
end
# ── Private helpers ──────────────────────────────────────────────────
defp get_nav_items(%__MODULE__{header_nav: items}, "header"), do: items
defp get_nav_items(%__MODULE__{footer_nav: items}, "footer"), do: items
defp put_nav_items(state, "header", items), do: %{state | header_nav: items}
defp put_nav_items(state, "footer", items), do: %{state | footer_nav: items}
# Create a comparable snapshot of the state (excludes _original)
defp snapshot(%__MODULE__{} = state) do
%{
announcement_text: state.announcement_text,
announcement_link: state.announcement_link,
announcement_style: state.announcement_style,
footer_about: state.footer_about,
footer_copyright: state.footer_copyright,
show_newsletter: state.show_newsletter,
header_nav: Enum.map(state.header_nav, &nav_item_snapshot/1),
footer_nav: Enum.map(state.footer_nav, &nav_item_snapshot/1),
social_links: Enum.map(state.social_links, &social_link_snapshot/1)
}
end
# Only compare fields that matter for dirty detection
defp nav_item_snapshot(%NavItem{} = item) do
%{id: item.id, label: item.label, url: item.url}
end
defp social_link_snapshot(%SocialLink{} = link) do
%{id: link.id, platform: link.platform, url: link.url}
end
# Sync nav items - create new, update changed, delete removed
defp sync_nav_items(current, original) do
original_ids = MapSet.new(Enum.map(original, & &1.id))
current_ids = MapSet.new(Enum.map(current, & &1.id))
# Delete items that were removed
deleted_ids = MapSet.difference(original_ids, current_ids)
for id <- deleted_ids do
item = Enum.find(original, &(&1.id == id))
if item, do: Site.delete_nav_item(item)
end
# Create or update items
Enum.map(current, fn item ->
if MapSet.member?(original_ids, item.id) do
# Existing item - update if changed
orig = Enum.find(original, &(&1.id == item.id))
if orig.label != item.label or orig.url != item.url do
{:ok, updated} = Site.update_nav_item(orig, %{label: item.label, url: item.url})
updated
else
orig
end
else
# New item - create
{:ok, created} =
Site.create_nav_item(%{
location: item.location,
label: item.label,
url: item.url,
position: item.position
})
created
end
end)
end
# Sync social links - create new, update changed, delete removed
defp sync_social_links(current, original) do
original_ids = MapSet.new(Enum.map(original, & &1.id))
current_ids = MapSet.new(Enum.map(current, & &1.id))
# Delete links that were removed
deleted_ids = MapSet.difference(original_ids, current_ids)
for id <- deleted_ids do
link = Enum.find(original, &(&1.id == id))
if link, do: Site.delete_social_link(link)
end
# Create or update links
Enum.map(current, fn link ->
if MapSet.member?(original_ids, link.id) do
# Existing link - update if changed
orig = Enum.find(original, &(&1.id == link.id))
if orig.platform != link.platform or orig.url != link.url do
{:ok, updated} =
Site.update_social_link(orig, %{platform: link.platform, url: link.url})
updated
else
orig
end
else
# New link - create
{:ok, created} =
Site.create_social_link(%{
platform: link.platform,
url: link.url,
position: link.position
})
created
end
end)
end
end