diff --git a/lib/phoenix_kit/utils/slug.ex b/lib/phoenix_kit/utils/slug.ex
new file mode 100644
index 000000000..952f87877
--- /dev/null
+++ b/lib/phoenix_kit/utils/slug.ex
@@ -0,0 +1,60 @@
+defmodule PhoenixKit.Utils.Slug do
+ @moduledoc """
+ Helpers for generating consistent, URL-friendly slugs across PhoenixKit.
+
+ Provides functions to slugify arbitrary text, enforce separator rules,
+ and ensure uniqueness by delegating existence checks via callback.
+ """
+
+ @doc """
+ Converts the given `text` into a slug.
+
+ Options:
+ * `:separator` - character used between words (defaults to "-")
+
+ Returns an empty string when the input is blank or cannot be converted.
+ """
+ @spec slugify(String.t() | nil, keyword()) :: String.t()
+ def slugify(text, opts \\ [])
+
+ def slugify(nil, _opts), do: ""
+
+ def slugify(text, opts) when is_binary(text) do
+ separator = Keyword.get(opts, :separator, "-")
+ escaped_separator = Regex.escape(separator)
+
+ text
+ |> String.downcase()
+ |> String.replace(~r/[^a-z0-9]+/u, separator)
+ |> String.replace(~r/#{escaped_separator}+/, separator)
+ |> String.trim(separator)
+ end
+
+ def slugify(_text, _opts), do: ""
+
+ @doc """
+ Ensures the provided slug is unique by calling `exists_fun`.
+
+ `exists_fun` should return truthy when the slug is already taken.
+ """
+ @spec ensure_unique(String.t(), (String.t() -> boolean())) :: String.t()
+ def ensure_unique("", _exists_fun), do: ""
+
+ def ensure_unique(slug, exists_fun) when is_function(exists_fun, 1) do
+ if exists_fun.(slug) do
+ increment_slug(slug, 2, exists_fun)
+ else
+ slug
+ end
+ end
+
+ defp increment_slug(base_slug, counter, exists_fun) do
+ candidate = "#{base_slug}-#{counter}"
+
+ if exists_fun.(candidate) do
+ increment_slug(base_slug, counter + 1, exists_fun)
+ else
+ candidate
+ end
+ end
+end
diff --git a/lib/phoenix_kit_web/live/modules.html.heex b/lib/phoenix_kit_web/live/modules.html.heex
index 2ba27d4b8..a7c23610e 100644
--- a/lib/phoenix_kit_web/live/modules.html.heex
+++ b/lib/phoenix_kit_web/live/modules.html.heex
@@ -301,38 +301,6 @@
enabled={@blogging_enabled}
toggle_event="toggle_blogging"
>
- <:info>
- <% url_prefix =
- PhoenixKit.Config.get_url_prefix()
- |> case do
- "/" -> ""
- prefix -> prefix
- end %>
-
-
-
- {gettext("Key paths & URLs")}
-
-
- -
-
- {gettext("Post files on disk:")}
-
-
- priv/blogging/<blog>/<slug>/<locale>.phk
-
-
- -
-
- {gettext("Public URL pattern:")}
-
-
- {url_prefix}/:locale/:blog_slug/:post_slug
-
-
-
-
-
<:status_badges>
- <.icon name="hero-document-text" class="w-4 h-4 mr-1" /> {gettext("Open Blogging")}
+ <.icon name="hero-document-text" class="w-4 h-4 mr-1" /> {gettext("Open")}
<.link
navigate={
@@ -360,10 +328,38 @@
}
class="btn btn-sm btn-outline"
>
- <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> {gettext("Manage Blogging")}
+ <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> {gettext("Configure")}
+
+ <:stats>
+ <% url_prefix =
+ PhoenixKit.Config.get_url_prefix()
+ |> case do
+ "/" -> ""
+ prefix -> prefix
+ end %>
+
+
+
+
+ {gettext("Post files on disk:")}
+
+
+ priv/blogging/<blog>/<slug>/<locale>.phk
+
+
+
+
+ {gettext("Public URL pattern:")}
+
+
+ {url_prefix}/:locale/:blog_slug/:post_slug
+
+
+
+
<%!-- SEO Module --%>
@@ -374,27 +370,13 @@
enabled={@seo_module_enabled}
toggle_event="toggle_seo_module"
>
- <:info>
-
- Toggle the SEO module to unlock dedicated settings for search engines.
-
-
-
<:status_badges>
- {if @seo_module_enabled, do: "Module Enabled", else: "Module Disabled"}
+ {if @seo_module_enabled, do: "Enabled", else: "Disabled"}
- <%= if @seo_module_enabled do %>
-
- {if @seo_no_index_enabled, do: "Noindex Active", else: "Indexing Allowed"}
-
- <% end %>
<:action_buttons>
@@ -404,9 +386,18 @@
}
class={"btn btn-sm w-full #{if @seo_module_enabled, do: "btn-primary", else: "btn-outline"}"}
>
- <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure SEO
+ <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure
+
+ <:stats>
+
+ Noindex:
+
+ {if @seo_no_index_enabled, do: "On", else: "Off"}
+
+
+
<%!-- Pages module remains disabled but code retained for future use --%>
diff --git a/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex b/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex
index d72f746ae..3f778a03d 100644
--- a/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex
+++ b/lib/phoenix_kit_web/live/modules/blogging/context/storage.ex
@@ -13,6 +13,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Settings
+ alias PhoenixKit.Utils.Slug
alias PhoenixKitWeb.Live.Modules.Blogging.Metadata
@doc """
@@ -117,6 +118,14 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do
|> File.dir?()
end
+ defp slug_exists_for_generation?(_blog_slug, candidate, current_slug)
+ when not is_nil(current_slug) and candidate == current_slug,
+ do: false
+
+ defp slug_exists_for_generation?(blog_slug, candidate, _current_slug) do
+ slug_exists?(blog_slug, candidate)
+ end
+
@doc """
Generates a unique slug based on title and optional preferred slug.
@@ -124,19 +133,21 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do
- `:invalid_format` - slug has invalid format
- `:reserved_language_code` - slug is a reserved language code
"""
- @spec generate_unique_slug(String.t(), String.t(), String.t() | nil) ::
+ @spec generate_unique_slug(String.t(), String.t(), String.t() | nil, keyword()) ::
{:ok, String.t()} | {:error, :invalid_format | :reserved_language_code}
- def generate_unique_slug(blog_slug, title, preferred_slug \\ nil) do
+ def generate_unique_slug(blog_slug, title, preferred_slug \\ nil, opts \\ []) do
+ current_slug = Keyword.get(opts, :current_slug)
+
base_slug_result =
case preferred_slug do
nil ->
- {:ok, generate_slug_from_title(title)}
+ {:ok, Slug.slugify(title)}
slug when is_binary(slug) ->
- sanitized = sanitize_slug(slug)
+ sanitized = Slug.slugify(slug)
if sanitized == "" do
- {:ok, generate_slug_from_title(title)}
+ {:ok, Slug.slugify(title)}
else
# Validate the sanitized slug
case validate_slug(sanitized) do
@@ -152,7 +163,10 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do
case base_slug_result do
{:ok, base_slug} when base_slug != "" ->
- {:ok, ensure_unique_slug(blog_slug, base_slug)}
+ {:ok,
+ Slug.ensure_unique(base_slug, fn candidate ->
+ slug_exists_for_generation?(blog_slug, candidate, current_slug)
+ end)}
{:ok, ""} ->
# Empty slug - return empty for auto-generation, will show placeholder
@@ -163,45 +177,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Storage do
end
end
- defp generate_slug_from_title(title) when is_binary(title) do
- title
- |> String.downcase()
- |> String.replace(~r/[^a-z0-9]+/u, "-")
- |> String.replace(~r/-+/, "-")
- |> String.trim("-")
- |> case do
- # Return empty string instead of generating fallback
- # This allows the form to show placeholder and user can enter custom slug
- "" -> ""
- slug -> slug
- end
- end
-
- defp sanitize_slug(slug) do
- slug
- |> String.downcase()
- |> String.replace(~r/[^a-z0-9]+/u, "-")
- |> String.replace(~r/-+/, "-")
- |> String.trim("-")
- end
-
- defp ensure_unique_slug(blog_slug, base_slug) do
- if slug_exists?(blog_slug, base_slug) do
- find_available_slug(blog_slug, base_slug, 2)
- else
- base_slug
- end
- end
-
- defp find_available_slug(blog_slug, base_slug, counter) do
- candidate = "#{base_slug}-#{counter}"
-
- if slug_exists?(blog_slug, candidate) do
- find_available_slug(blog_slug, base_slug, counter + 1)
- else
- candidate
- end
- end
+ # slug helpers now handled by PhoenixKit.Utils.Slug
@type post :: %{
blog: String.t() | nil,
diff --git a/lib/phoenix_kit_web/live/modules/blogging/editor.ex b/lib/phoenix_kit_web/live/modules/blogging/editor.ex
index 2f51059b6..3de00e9c1 100644
--- a/lib/phoenix_kit_web/live/modules/blogging/editor.ex
+++ b/lib/phoenix_kit_web/live/modules/blogging/editor.ex
@@ -32,6 +32,8 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
|> assign(:media_selected_ids, MapSet.new())
|> assign(:is_autosaving, false)
|> assign(:autosave_timer, nil)
+ |> assign(:slug_manually_set, false)
+ |> assign(:last_auto_slug, "")
{:ok, socket}
end
@@ -65,12 +67,14 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
now = DateTime.utc_now() |> DateTime.truncate(:second) |> floor_datetime_to_minute()
virtual_post = build_virtual_post(blog_slug, blog_mode, primary_language, now)
+ form = post_form(virtual_post)
+
socket =
socket
|> assign(:blog_mode, blog_mode)
|> assign(:post, virtual_post)
|> assign(:blog_name, Blogging.blog_name(blog_slug) || blog_slug)
- |> assign(:form, post_form(virtual_post))
+ |> assign_form_with_tracking(form, slug_manually_set: false)
|> assign(:content, "")
|> assign(:current_language, primary_language)
|> assign(:available_languages, virtual_post.available_languages)
@@ -116,11 +120,13 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
|> Map.put(:mode, post.mode)
|> Map.put(:slug, post.slug)
+ form = post_form(virtual_post)
+
socket
|> assign(:blog_mode, blog_mode)
|> assign(:post, virtual_post)
|> assign(:blog_name, Blogging.blog_name(blog_slug) || blog_slug)
- |> assign(:form, post_form(virtual_post))
+ |> assign_form_with_tracking(form, slug_manually_set: false)
|> assign(:content, "")
|> assign(:current_language, switch_to_lang)
|> assign(:available_languages, post.available_languages)
@@ -138,11 +144,13 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
|> assign(:public_url, nil)
|> push_event("changes-status", %{has_changes: false})
else
+ form = post_form(post)
+
socket
|> assign(:blog_mode, blog_mode)
|> assign(:post, %{post | blog: blog_slug})
|> assign(:blog_name, Blogging.blog_name(blog_slug) || blog_slug)
- |> assign(:form, post_form(post))
+ |> assign_form_with_tracking(form)
|> assign(:content, post.content)
|> assign(:current_language, post.language)
|> assign(:available_languages, post.available_languages)
@@ -187,6 +195,14 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
|> Map.merge(params)
|> normalize_form()
+ slug_manually_set =
+ if Map.has_key?(params, "slug") do
+ slug_value = Map.get(new_form, "slug", "")
+ slug_value != "" && slug_value != socket.assigns.last_auto_slug
+ else
+ socket.assigns.slug_manually_set
+ end
+
has_changes = dirty?(socket.assigns.post, new_form, socket.assigns.content)
# Update public_url if status changed
@@ -201,6 +217,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
socket =
socket
|> assign(:form, new_form)
+ |> assign(:slug_manually_set, slug_manually_set)
|> assign(:has_pending_changes, has_changes)
|> assign(:public_url, public_url)
|> clear_flash()
@@ -261,33 +278,24 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
# Clear the featured image from the form (form is a simple map, not a struct)
updated_form = Map.put(socket.assigns.form, "featured_image_id", "")
- {:noreply,
- socket
- |> assign(:form, updated_form)
- |> assign(:has_pending_changes, true)
- |> put_flash(:info, gettext("Featured image cleared"))
- |> push_event("changes-status", %{has_changes: true})}
+ socket =
+ socket
+ |> assign(:form, updated_form)
+ |> assign(:has_pending_changes, true)
+ |> put_flash(:info, gettext("Featured image cleared"))
+ |> push_event("changes-status", %{has_changes: true})
+ |> schedule_autosave()
+
+ {:noreply, socket}
end
def handle_event("generate_slug_from_content", _params, socket) do
# Only generate slug if in slug mode and slug is empty
if socket.assigns.blog_mode == "slug" do
- current_slug = Map.get(socket.assigns.form, "slug", "")
content = socket.assigns.content || ""
- new_slug =
- if current_slug == "" and String.trim(content) != "" do
- # Extract title from content and use it for slug
- title = Metadata.extract_title_from_content(content)
- Storage.generate_unique_slug(socket.assigns.blog_slug, title, nil)
- else
- current_slug
- end
-
- new_form =
- socket.assigns.form
- |> Map.put("slug", new_slug)
- |> normalize_form()
+ {socket, new_form, slug_events} =
+ maybe_update_slug_from_content(socket, content, force: true)
has_changes = dirty?(socket.assigns.post, new_form, socket.assigns.content)
@@ -296,40 +304,14 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
|> assign(:form, new_form)
|> assign(:has_pending_changes, has_changes)
|> push_event("changes-status", %{has_changes: has_changes})
- |> push_event("update-slug", %{slug: new_slug})}
+ |> push_slug_events(slug_events)}
else
{:noreply, socket}
end
end
def handle_event("update_content", %{"content" => content}, socket) do
- # Auto-generate slug from content live for new unsaved posts only
- is_new_unsaved =
- Map.get(socket.assigns, :is_new_post, false) ||
- Map.get(socket.assigns, :is_new_translation, false)
-
- {new_form, slug_events} =
- if socket.assigns.blog_mode == "slug" && is_new_unsaved && String.trim(content) != "" do
- title = Metadata.extract_title_from_content(content)
-
- case Storage.generate_unique_slug(socket.assigns.blog_slug, title, nil) do
- {:ok, new_slug} ->
- current_slug = Map.get(socket.assigns.form, "slug", "")
-
- if new_slug != current_slug do
- {Map.put(socket.assigns.form, "slug", new_slug),
- [{"update-slug", %{slug: new_slug}}]}
- else
- {socket.assigns.form, []}
- end
-
- {:error, _} ->
- # If slug generation fails, keep current form
- {socket.assigns.form, []}
- end
- else
- {socket.assigns.form, []}
- end
+ {socket, new_form, slug_events} = maybe_update_slug_from_content(socket, content)
has_changes = dirty?(socket.assigns.post, new_form, content)
@@ -341,9 +323,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
|> push_event("changes-status", %{has_changes: has_changes})
socket =
- Enum.reduce(slug_events, socket, fn {event, data}, acc ->
- push_event(acc, event, data)
- end)
+ push_slug_events(socket, slug_events)
# Trigger debounced autosave if changes detected
socket =
@@ -448,7 +428,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
{:noreply,
socket
|> assign(:post, virtual_post)
- |> assign(:form, post_form(virtual_post))
+ |> assign_form_with_tracking(post_form(virtual_post), slug_manually_set: false)
|> assign(:content, "")
|> assign(:current_language, new_language)
|> assign(:has_pending_changes, false)
@@ -485,30 +465,38 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
file_id = List.first(file_ids)
inserting_image_component = Map.get(socket.assigns, :inserting_image_component, false)
- socket =
- if file_id && inserting_image_component do
- # Insert image component at cursor via JavaScript
- js_code = "window.insertImageComponent && window.insertImageComponent('#{file_id}')"
+ {socket, autosave?} =
+ cond do
+ file_id && inserting_image_component ->
+ # Insert image component at cursor via JavaScript
+ js_code = "window.insertImageComponent && window.insertImageComponent('#{file_id}')"
- socket
- |> assign(:show_media_selector, false)
- |> assign(:inserting_image_component, false)
- |> put_flash(:info, gettext("Image component inserted"))
- |> push_event("exec-js", %{js: js_code})
- else
- if file_id do
- socket
- |> assign(:form, update_form_with_media(socket.assigns.form, file_id))
- |> assign(:has_pending_changes, true)
- |> assign(:show_media_selector, false)
- |> put_flash(:info, gettext("Featured image selected"))
- |> push_event("changes-status", %{has_changes: true})
- else
- socket
- |> assign(:show_media_selector, false)
- end
+ {
+ socket
+ |> assign(:show_media_selector, false)
+ |> assign(:inserting_image_component, false)
+ |> put_flash(:info, gettext("Image component inserted"))
+ |> push_event("exec-js", %{js: js_code}),
+ false
+ }
+
+ file_id ->
+ {
+ socket
+ |> assign(:form, update_form_with_media(socket.assigns.form, file_id))
+ |> assign(:has_pending_changes, true)
+ |> assign(:show_media_selector, false)
+ |> put_flash(:info, gettext("Featured image selected"))
+ |> push_event("changes-status", %{has_changes: true}),
+ true
+ }
+
+ true ->
+ {socket |> assign(:show_media_selector, false), false}
end
+ socket = if autosave?, do: schedule_autosave(socket), else: socket
+
{:noreply, socket}
end
@@ -625,6 +613,8 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
defp update_existing_post(socket, params) do
scope = socket.assigns[:phoenix_kit_current_scope]
+ old_path = socket.assigns.post.path
+
case Blogging.update_post(socket.assigns.blog_slug, socket.assigns.post, params, %{
scope: scope
}) do
@@ -637,13 +627,16 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
do: nil,
else: gettext("Post saved")
+ form = post_form(post)
+
socket =
socket
|> assign(:post, post)
- |> assign(:form, post_form(post))
+ |> assign_form_with_tracking(form)
|> assign(:content, post.content)
|> assign(:has_pending_changes, false)
|> push_event("changes-status", %{has_changes: false})
+ |> maybe_update_current_path(old_path, post.path)
{:noreply, if(flash_message, do: put_flash(socket, :info, flash_message), else: socket)}
@@ -697,10 +690,12 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
do: nil,
else: success_message
+ form = post_form(updated_post)
+
socket =
socket
|> assign(:post, updated_post)
- |> assign(:form, post_form(updated_post))
+ |> assign_form_with_tracking(form)
|> assign(:content, updated_post.content)
|> assign(:available_languages, updated_post.available_languages)
|> assign(:has_pending_changes, false)
@@ -1068,7 +1063,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
|> assign(:blog_mode, mode_to_string(mode))
|> assign(:blog_slug, blog_slug)
|> assign(:post, post)
- |> assign(:form, form)
+ |> assign_form_with_tracking(form, slug_manually_set: false)
|> assign(:content, data[:content] || "")
|> assign(:current_language, language)
|> assign(:available_languages, post.available_languages)
@@ -1309,4 +1304,113 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do
# The form is a simple map with string keys
Map.put(form, "featured_image_id", file_id)
end
+
+ defp assign_form_with_tracking(socket, form, opts \\ []) do
+ slug = Map.get(form, "slug", "")
+
+ slug_manually_set =
+ case Keyword.fetch(opts, :slug_manually_set) do
+ {:ok, value} -> value
+ :error -> Map.get(socket.assigns, :slug_manually_set, false)
+ end
+
+ last_auto_slug =
+ case Keyword.fetch(opts, :last_auto_slug) do
+ {:ok, value} -> value
+ :error -> slug
+ end
+
+ socket
+ |> assign(:form, form)
+ |> assign(:last_auto_slug, last_auto_slug)
+ |> assign(:slug_manually_set, slug_manually_set)
+ end
+
+ defp maybe_update_slug_from_content(socket, content, opts \\ []) do
+ force? = Keyword.get(opts, :force, false)
+ content = content || ""
+
+ cond do
+ socket.assigns.blog_mode != "slug" ->
+ no_slug_update(socket)
+
+ not force? && Map.get(socket.assigns, :slug_manually_set, false) ->
+ no_slug_update(socket)
+
+ String.trim(content) == "" ->
+ no_slug_update(socket)
+
+ true ->
+ update_slug_from_content(socket, content)
+ end
+ end
+
+ defp no_slug_update(socket), do: {socket, socket.assigns.form, []}
+
+ defp update_slug_from_content(socket, content) do
+ title = Metadata.extract_title_from_content(content)
+ current_slug = socket.assigns.post.slug || Map.get(socket.assigns.form, "slug", "")
+
+ case Storage.generate_unique_slug(socket.assigns.blog_slug, title, nil,
+ current_slug: current_slug
+ ) do
+ {:ok, ""} ->
+ no_slug_update(socket)
+
+ {:ok, new_slug} ->
+ apply_new_slug(socket, new_slug)
+
+ {:error, _reason} ->
+ no_slug_update(socket)
+ end
+ end
+
+ defp apply_new_slug(socket, new_slug) do
+ current_slug = Map.get(socket.assigns.form, "slug", "")
+
+ if new_slug != current_slug do
+ form =
+ socket.assigns.form
+ |> Map.put("slug", new_slug)
+ |> normalize_form()
+
+ socket =
+ socket
+ |> assign(:last_auto_slug, new_slug)
+ |> assign(:slug_manually_set, false)
+
+ {socket, form, [{"update-slug", %{slug: new_slug}}]}
+ else
+ socket =
+ socket
+ |> assign(:last_auto_slug, new_slug)
+ |> assign(:slug_manually_set, false)
+
+ {socket, socket.assigns.form, []}
+ end
+ end
+
+ defp push_slug_events(socket, events) do
+ Enum.reduce(events, socket, fn {event, data}, acc ->
+ push_event(acc, event, data)
+ end)
+ end
+
+ defp maybe_update_current_path(socket, old_path, new_path)
+ when new_path in [nil, ""] or new_path == old_path,
+ do: socket
+
+ defp maybe_update_current_path(socket, _old_path, new_path) do
+ encoded = URI.encode(new_path)
+
+ path =
+ Routes.path(
+ "/admin/blogging/#{socket.assigns.blog_slug}/edit?path=#{encoded}",
+ locale: socket.assigns.current_locale
+ )
+
+ socket
+ |> assign(:current_path, path)
+ |> push_patch(to: path)
+ end
end
diff --git a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex
index 0f44f055e..fc78dcf9e 100644
--- a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex
+++ b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex
@@ -9,21 +9,24 @@
current_locale={@current_locale}
>
<% nonce = assigns[:script_csp_nonce] || assigns[:csp_nonce] || "" %>
-
- <.icon name="hero-exclamation-triangle" class="w-5 h-5 shrink-0" />
-
-
- {gettext("Interactive editor features need JavaScript")}
-
-
- {gettext(
- "If you cannot insert components or see unsaved-change prompts, enable JavaScript or allow inline scripts for this page."
- )}
-
+ <%!-- Wrapper div to prevent LiveView from re-rendering the warning --%>
+
+
+ <.icon name="hero-exclamation-triangle" class="w-5 h-5 shrink-0" />
+
+
+ {gettext("Interactive editor features need JavaScript")}
+
+
+ {gettext(
+ "If you cannot insert components or see unsaved-change prompts, enable JavaScript or allow inline scripts for this page."
+ )}
+
+
@@ -578,6 +581,24 @@
{gettext("Saved")}
<% end %>
+
diff --git a/lib/phoenix_kit_web/live/modules/entities/data_form.ex b/lib/phoenix_kit_web/live/modules/entities/data_form.ex
index 8fe3ad6a3..885e5ae63 100644
--- a/lib/phoenix_kit_web/live/modules/entities/data_form.ex
+++ b/lib/phoenix_kit_web/live/modules/entities/data_form.ex
@@ -15,6 +15,7 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.DataForm do
alias PhoenixKit.Entities.PresenceHelpers
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Routes
+ alias PhoenixKit.Utils.Slug
@impl true
def mount(%{"entity_slug" => entity_slug, "id" => id} = params, _session, socket) do
@@ -201,6 +202,8 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.DataForm do
@impl true
def handle_event("validate", %{"phoenix_kit_entity_data" => data_params}, socket) do
if socket.assigns[:lock_owner?] do
+ entity_id = socket.assigns.entity.id
+ record_id = socket.assigns.data_record.id
form_data = Map.get(data_params, "data", %{})
data_params =
@@ -211,16 +214,16 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.DataForm do
end
data_params =
- if is_nil(socket.assigns.data_record.id) do
+ if is_nil(record_id) do
title = data_params["title"] || ""
current_slug = data_params["slug"] || ""
current_data = Ecto.Changeset.apply_changes(socket.assigns.changeset)
previous_title = current_data.title || ""
- auto_generated_slug = generate_slug_from_title(previous_title)
+ auto_generated_slug = auto_generate_entity_slug(entity_id, record_id, previous_title)
if current_slug == "" || current_slug == auto_generated_slug do
- Map.put(data_params, "slug", generate_slug_from_title(title))
+ Map.put(data_params, "slug", auto_generate_entity_slug(entity_id, record_id, title))
else
data_params
end
@@ -370,6 +373,8 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.DataForm do
def handle_event("generate_slug", _params, socket) do
if socket.assigns[:lock_owner?] do
changeset = socket.assigns.changeset
+ entity_id = socket.assigns.entity.id
+ record_id = socket.assigns.data_record.id
# Get title from changeset (includes both changes and original data)
title = Ecto.Changeset.get_field(changeset, :title) || ""
@@ -378,8 +383,8 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.DataForm do
if title == "" do
{:noreply, socket}
else
- # Generate slug from title
- slug = generate_slug_from_title(title)
+ # Generate slug from title using shared utility
+ slug = auto_generate_entity_slug(entity_id, record_id, title)
# Get ALL current field values from the changeset
# This includes both changed values and original struct values
@@ -698,16 +703,25 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.DataForm do
defp normalize_record_key(key) when is_binary(key), do: key
defp normalize_record_key(key), do: to_string(key)
- defp generate_slug_from_title(title) when is_binary(title) do
+ defp auto_generate_entity_slug(_entity_id, _record_id, title) when title in [nil, ""], do: ""
+
+ defp auto_generate_entity_slug(entity_id, current_record_id, title) do
title
- |> String.downcase()
- |> String.replace(~r/[^\w\s-]/, "")
- |> String.replace(~r/\s+/, "-")
- |> String.replace(~r/-+/, "-")
- |> String.trim("-")
+ |> Slug.slugify()
+ |> Slug.ensure_unique(&slug_taken_by_other?(entity_id, &1, current_record_id))
end
- defp generate_slug_from_title(_), do: ""
+ defp slug_taken_by_other?(_entity_id, "", _current_record_id), do: false
+
+ defp slug_taken_by_other?(entity_id, candidate, current_record_id) do
+ case EntityData.get_by_slug(entity_id, candidate) do
+ nil ->
+ false
+
+ %EntityData{id: id} ->
+ is_nil(current_record_id) || id != current_record_id
+ end
+ end
defp populate_presence_info(socket, type, id) do
# Get all presences sorted by joined_at (FIFO order)
diff --git a/lib/phoenix_kit_web/live/modules/entities/data_form.html.heex b/lib/phoenix_kit_web/live/modules/entities/data_form.html.heex
index 73582fe16..774280527 100644
--- a/lib/phoenix_kit_web/live/modules/entities/data_form.html.heex
+++ b/lib/phoenix_kit_web/live/modules/entities/data_form.html.heex
@@ -109,6 +109,8 @@
value={Ecto.Changeset.get_field(@changeset, :slug) || ""}
placeholder={gettext("auto-generated-slug")}
class="input input-bordered w-full"
+ pattern="[a-z0-9]+(?:-[a-z0-9]+)*"
+ title={gettext("Use lowercase letters, numbers, and hyphens only.")}
phx-debounce="300"
disabled={@readonly?}
/>
diff --git a/lib/phoenix_kit_web/live/modules/entities/entity_form.ex b/lib/phoenix_kit_web/live/modules/entities/entity_form.ex
index c6d0edd84..7254058ed 100644
--- a/lib/phoenix_kit_web/live/modules/entities/entity_form.ex
+++ b/lib/phoenix_kit_web/live/modules/entities/entity_form.ex
@@ -15,6 +15,7 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do
alias PhoenixKit.Settings
alias PhoenixKit.Utils.HeroIcons
alias PhoenixKit.Utils.Routes
+ alias PhoenixKit.Utils.Slug
@impl true
def mount(%{"id" => id} = params, _session, socket) do
@@ -70,8 +71,9 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do
|> assign(:field_types, FieldTypes.for_picker())
|> assign(:show_field_form, false)
|> assign(:editing_field_index, nil)
- |> assign(:field_form, %{})
+ |> assign(:field_form, new_field_form())
|> assign(:field_error, nil)
+ |> assign(:field_key_manually_set, false)
|> assign(:show_icon_picker, false)
|> assign(:icon_search, "")
|> assign(:selected_category, "All")
@@ -222,7 +224,6 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do
changeset =
socket.assigns.entity
|> Entities.change_entity(entity_params)
- |> Map.put(:action, :validate)
socket = assign(socket, :changeset, changeset)
@@ -289,7 +290,7 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do
|> assign(:fields, fields)
|> assign(:show_field_form, false)
|> assign(:editing_field_index, nil)
- |> assign(:field_form, %{})
+ |> assign(:field_form, new_field_form())
|> assign(:field_error, nil)
|> assign(:show_icon_picker, false)
|> assign(:delete_confirm_index, nil)
@@ -424,14 +425,8 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do
socket
|> assign(:show_field_form, true)
|> assign(:editing_field_index, nil)
- |> assign(:field_form, %{
- "type" => "text",
- "key" => "",
- "label" => "",
- "required" => false,
- "default" => "",
- "options" => []
- })
+ |> assign(:field_form, new_field_form())
+ |> assign(:field_key_manually_set, false)
|> assign(:field_error, nil)
|> assign(:delete_confirm_index, nil)
@@ -450,7 +445,8 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do
socket
|> assign(:show_field_form, true)
|> assign(:editing_field_index, index)
- |> assign(:field_form, field || %{})
+ |> assign(:field_form, normalize_field_form(field) || %{})
+ |> assign(:field_key_manually_set, true)
|> assign(:field_error, nil)
reply_with_broadcast(socket)
@@ -464,7 +460,8 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do
socket
|> assign(:show_field_form, false)
|> assign(:editing_field_index, nil)
- |> assign(:field_form, %{})
+ |> assign(:field_form, new_field_form())
+ |> assign(:field_key_manually_set, false)
|> assign(:field_error, nil)
reply_with_broadcast(socket)
@@ -554,38 +551,28 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do
end
end
- def handle_event("update_field_form", %{"field" => field_params}, socket) do
+ def handle_event("update_field_form", %{"field" => field_params} = params, socket) do
if socket.assigns[:lock_owner?] do
+ target = Map.get(params, "_target", [])
+ manual_key? = manual_key_target?(target)
+
+ field_params =
+ if manual_key?, do: field_params, else: Map.delete(field_params, "key")
+
# Update field form with live changes
- current_form = socket.assigns.field_form
- updated_form = Map.merge(current_form, field_params)
+ current_form = normalize_field_form(socket.assigns.field_form)
- # Auto-generate key from label when adding new field (not editing)
updated_form =
- if is_nil(socket.assigns.editing_field_index) do
- # Only auto-generate if label changed and key wasn't manually edited
- label = updated_form["label"] || ""
- current_key = updated_form["key"] || ""
-
- # Check if the current key was auto-generated from the previous label
- previous_label = current_form["label"] || ""
- auto_generated_key = generate_slug_from_name(previous_label)
-
- # If key matches the auto-generated one or is empty, update it
- if current_key == "" || current_key == auto_generated_key do
- Map.put(updated_form, "key", generate_slug_from_name(label))
- else
- # User manually edited the key, don't overwrite it
- updated_form
- end
- else
- # In edit mode, don't auto-generate
- updated_form
- end
+ current_form
+ |> Map.merge(field_params)
+ |> maybe_auto_update_field_key(current_form, socket.assigns.field_key_manually_set,
+ editing?: socket.assigns.editing_field_index != nil
+ )
socket =
socket
|> assign(:field_form, updated_form)
+ |> assign(:field_key_manually_set, manual_key? || socket.assigns.field_key_manually_set)
# Clear error when user makes changes
|> assign(:field_error, nil)
@@ -914,7 +901,7 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do
|> assign(:fields, fields)
|> assign(:show_field_form, false)
|> assign(:editing_field_index, nil)
- |> assign(:field_form, %{})
+ |> assign(:field_form, new_field_form())
|> assign(:field_error, nil)
end
@@ -1040,18 +1027,68 @@ defmodule PhoenixKitWeb.Live.Modules.Entities.EntityForm do
"entities-form-" <> Base.url_encode64(:crypto.strong_rand_bytes(6), padding: false))
end
- defp generate_slug_from_name(name) when is_binary(name) do
- name
- |> String.downcase()
- |> String.replace(~r/[^\w\s-]/, "")
- |> String.replace(~r/\s+/, "_")
- |> String.replace(~r/-+/, "_")
- |> String.replace(~r/_+/, "_")
- |> String.trim("_")
- end
+ defp generate_slug_from_name(name) when is_binary(name),
+ do: Slug.slugify(name, separator: "_")
defp generate_slug_from_name(_), do: ""
+ defp new_field_form do
+ %{
+ "type" => "text",
+ "key" => "",
+ "label" => "",
+ "required" => false,
+ "default" => "",
+ "options" => []
+ }
+ end
+
+ defp normalize_field_form(nil), do: new_field_form()
+
+ defp normalize_field_form(field) when is_map(field) do
+ Enum.reduce(field, %{}, fn {key, value}, acc ->
+ cond do
+ is_binary(key) -> Map.put(acc, key, value)
+ is_atom(key) -> Map.put(acc, Atom.to_string(key), value)
+ true -> acc
+ end
+ end)
+ end
+
+ defp maybe_auto_update_field_key(updated_form, previous_form, manual?, opts) do
+ if manual? || Keyword.get(opts, :editing?, false) do
+ updated_form
+ else
+ auto_update_field_key(updated_form, previous_form)
+ end
+ end
+
+ defp auto_update_field_key(updated_form, previous_form) do
+ label = fetch_form_value(updated_form, "label") || ""
+ current_key = fetch_form_value(updated_form, "key") || ""
+ previous_label = fetch_form_value(previous_form, "label") || ""
+ auto_generated_key = generate_slug_from_name(previous_label)
+
+ if label != "" && (current_key == "" || current_key == auto_generated_key) do
+ Map.put(updated_form, "key", generate_slug_from_name(label))
+ else
+ updated_form
+ end
+ end
+
+ defp fetch_form_value(form, key) do
+ Map.get(form, key) ||
+ case key do
+ "label" -> Map.get(form, :label)
+ "key" -> Map.get(form, :key)
+ "type" -> Map.get(form, :type)
+ _ -> nil
+ end
+ end
+
+ defp manual_key_target?(["field", "key"]), do: true
+ defp manual_key_target?(_), do: false
+
defp populate_presence_info(socket, type, id) do
# Get all presences sorted by joined_at (FIFO order)
presences = PresenceHelpers.get_sorted_presences(type, id)
diff --git a/lib/phoenix_kit_web/live/modules/entities/entity_form.html.heex b/lib/phoenix_kit_web/live/modules/entities/entity_form.html.heex
index 733df52fb..363979d69 100644
--- a/lib/phoenix_kit_web/live/modules/entities/entity_form.html.heex
+++ b/lib/phoenix_kit_web/live/modules/entities/entity_form.html.heex
@@ -425,6 +425,7 @@