400 lines
12 KiB
Elixir
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
|