add nav editors to Site tab with live preview
All checks were successful
deploy / deploy (push) Successful in 3m27s
All checks were successful
deploy / deploy (push) Successful in 3m27s
- 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>
This commit is contained in:
399
lib/berrypod_web/site_editor_state.ex
Normal file
399
lib/berrypod_web/site_editor_state.ex
Normal file
@@ -0,0 +1,399 @@
|
||||
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
|
||||
Reference in New Issue
Block a user