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