From 37f305a42979d556de6bddd437b4012210494470 Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 18 Nov 2025 23:48:58 +0200 Subject: [PATCH 01/14] Add SEO module with configurable noindex directive * introduce PhoenixKit.Modules.SEO plus defaults and persistence keys * expose new `/admin/settings/seo` LiveView, router entry, and sidebar link * update modules dashboard card/toggle and inject robots meta tags when enabled --- lib/modules/seo/seo.ex | 81 ++++++++++++++++++ lib/phoenix_kit/settings/settings.ex | 3 + lib/phoenix_kit_web/components/admin_nav.ex | 2 + .../components/layout_wrapper.ex | 20 ++++- .../components/layouts/root.html.heex | 4 + lib/phoenix_kit_web/integration.ex | 4 + lib/phoenix_kit_web/live/modules.ex | 44 ++++++++++ lib/phoenix_kit_web/live/modules.html.heex | 44 ++++++++++ lib/phoenix_kit_web/live/settings/seo.ex | 84 +++++++++++++++++++ .../live/settings/seo.html.heex | 69 +++++++++++++++ 10 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 lib/modules/seo/seo.ex create mode 100644 lib/phoenix_kit_web/live/settings/seo.ex create mode 100644 lib/phoenix_kit_web/live/settings/seo.html.heex diff --git a/lib/modules/seo/seo.ex b/lib/modules/seo/seo.ex new file mode 100644 index 000000000..7abe77df5 --- /dev/null +++ b/lib/modules/seo/seo.ex @@ -0,0 +1,81 @@ +defmodule PhoenixKit.Modules.SEO do + @moduledoc """ + SEO module for PhoenixKit. + + Provides project-wide search visibility controls. Currently supports a + `noindex, nofollow` directive for staging environments, and will be extended + with additional SEO options in the future. + """ + alias PhoenixKit.Settings + + @module_enabled_key "seo_module_enabled" + @no_index_key "seo_no_index" + @module_name "seo" + + @doc """ + Indicates whether the SEO module is available in the admin. + """ + def module_enabled? do + Settings.get_boolean_setting(@module_enabled_key, false) + end + + @doc """ + Enables the SEO module (exposes the settings page). + """ + def enable_module do + Settings.update_boolean_setting_with_module(@module_enabled_key, true, @module_name) + end + + @doc """ + Disables the SEO module and clears any active directives. + """ + def disable_module do + case Settings.update_boolean_setting_with_module(@module_enabled_key, false, @module_name) do + {:ok, _setting} = result -> + # Ensure site becomes indexable once the module is disabled + _ = update_no_index(false) + result + + {:error, _changeset} = error -> + error + end + end + + @doc """ + Returns true when the `noindex, nofollow` directive is active. + """ + def no_index_enabled? do + Settings.get_boolean_setting(@no_index_key, false) + end + + @doc """ + Enables the global `noindex, nofollow` directive. + """ + def enable_no_index do + update_no_index(true) + end + + @doc """ + Disables the global `noindex, nofollow` directive. + """ + def disable_no_index do + update_no_index(false) + end + + @doc """ + Updates the directive to the provided boolean value. + """ + def update_no_index(enabled?) when is_boolean(enabled?) do + Settings.update_boolean_setting_with_module(@no_index_key, enabled?, @module_name) + end + + @doc """ + Returns configuration metadata for dashboard cards and settings pages. + """ + def get_config do + %{ + module_enabled: module_enabled?(), + no_index_enabled: no_index_enabled?() + } + end +end diff --git a/lib/phoenix_kit/settings/settings.ex b/lib/phoenix_kit/settings/settings.ex index 4e52c9b36..dc6584801 100644 --- a/lib/phoenix_kit/settings/settings.ex +++ b/lib/phoenix_kit/settings/settings.ex @@ -138,6 +138,9 @@ defmodule PhoenixKit.Settings do "sqs_polling_interval_ms" => "5000", "sqs_max_messages_per_poll" => "10", "sqs_visibility_timeout" => "300", + # SEO + "seo_module_enabled" => "false", + "seo_no_index" => "false", # OAuth Provider Credentials "oauth_google_client_id" => "", "oauth_google_client_secret" => "", diff --git a/lib/phoenix_kit_web/components/admin_nav.ex b/lib/phoenix_kit_web/components/admin_nav.ex index a55a5c970..0d800fe16 100644 --- a/lib/phoenix_kit_web/components/admin_nav.ex +++ b/lib/phoenix_kit_web/components/admin_nav.ex @@ -117,6 +117,8 @@ defmodule PhoenixKitWeb.Components.AdminNav do <.icon name="hero-cube" class="w-5 h-5" /> <% "language" -> %> <.icon name="hero-language" class="w-5 h-5" /> + <% "seo" -> %> + <.icon name="hero-magnifying-glass-circle" class="w-5 h-5" /> <% "document" -> %> <.icon name="hero-document-text" class="w-5 h-5" /> <% "maintenance" -> %> diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index 9e21a8c12..72370c388 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -35,6 +35,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do alias Phoenix.HTML alias PhoenixKit.Config alias PhoenixKit.Module.Languages + alias PhoenixKit.Modules.SEO alias PhoenixKit.ThemeConfig alias PhoenixKit.Users.Auth.Scope alias PhoenixKit.Utils.PhoenixVersion @@ -79,6 +80,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do PhoenixKit.Settings.get_content_language() end) |> assign_new(:blogging_blogs, fn -> load_blogging_blogs() end) + |> assign_new(:seo_no_index, fn -> SEO.no_index_enabled?() end) # Handle both inner_content (Phoenix 1.7-) and inner_block (Phoenix 1.8+) assigns = normalize_content_assigns(assigns) @@ -435,7 +437,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do disable_active={true} /> - <%= if submenu_open?(@current_path, ["/admin/settings", "/admin/settings/users", "/admin/settings/referral-codes", "/admin/settings/emails", "/admin/settings/languages", "/admin/settings/entities", "/admin/settings/storage", "/admin/settings/storage/dimensions", "/admin/settings/maintenance", "/admin/settings/blogging"]) do %> + <%= if submenu_open?(@current_path, ["/admin/settings", "/admin/settings/users", "/admin/settings/referral-codes", "/admin/settings/emails", "/admin/settings/languages", "/admin/settings/entities", "/admin/settings/storage", "/admin/settings/storage/dimensions", "/admin/settings/maintenance", "/admin/settings/blogging", "/admin/settings/seo"]) do %> <%!-- Settings submenu items --%>
<.admin_nav_item @@ -496,6 +498,16 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do /> <% end %> + <%= if SEO.module_enabled?() do %> + <.admin_nav_item + href={Routes.locale_aware_path(assigns, "/admin/settings/seo")} + icon="seo" + label="SEO" + current_path={@current_path || ""} + nested={true} + /> + <% end %> + <%= if PhoenixKit.Modules.Maintenance.module_enabled?() do %> <.admin_nav_item href={Routes.locale_aware_path(assigns, "/admin/settings/maintenance")} @@ -893,6 +905,10 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do <.live_title default={"#{assigns[:project_title] || "PhoenixKit"} Admin"}> {assigns[:page_title] || "Admin"} + <%= if assigns[:seo_no_index] do %> + + + <% end %> @@ -925,12 +941,14 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do |> Map.put_new(:phoenix_kit_integrated, true) |> Map.put_new(:phoenix_kit_version, get_phoenix_kit_version()) |> Map.put_new(:phoenix_version_info, PhoenixVersion.get_version_info()) + |> Map.put_new(:seo_no_index, assigns[:seo_no_index] || false) end # Prepare assigns specifically for PhoenixKit layout defp prepare_phoenix_kit_assigns(assigns) do assigns |> Map.put_new(:phoenix_kit_standalone, true) + |> Map.put_new(:seo_no_index, assigns[:seo_no_index] || false) end # Extract current user from scope for parent layout compatibility diff --git a/lib/phoenix_kit_web/components/layouts/root.html.heex b/lib/phoenix_kit_web/components/layouts/root.html.heex index 765455cc7..0185d0c29 100644 --- a/lib/phoenix_kit_web/components/layouts/root.html.heex +++ b/lib/phoenix_kit_web/components/layouts/root.html.heex @@ -8,6 +8,10 @@ + <%= if @seo_no_index do %> + + + <% end %> <.live_title default="PhoenixKit" suffix=" ยท Phoenix Framework"> {assigns[:page_title]} diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index 611f69ec4..08f60a325 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -306,6 +306,8 @@ defmodule PhoenixKitWeb.Integration do Live.Modules.Maintenance.Settings, :index + live "/admin/settings/seo", Live.Settings.SEO, :index + live "/admin/settings/storage", Live.Settings.Storage, :index live "/admin/settings/storage/buckets/new", Live.Settings.Storage.BucketForm, :new live "/admin/settings/storage/buckets/:id/edit", Live.Settings.Storage.BucketForm, :edit @@ -438,6 +440,8 @@ defmodule PhoenixKitWeb.Integration do Live.Modules.Maintenance.Settings, :index + live "/admin/settings/seo", Live.Settings.SEO, :index + live "/admin/settings/storage", Live.Settings.Storage, :index live "/admin/settings/storage/buckets/new", Live.Settings.Storage.BucketForm, :new live "/admin/settings/storage/buckets/:id/edit", Live.Settings.Storage.BucketForm, :edit diff --git a/lib/phoenix_kit_web/live/modules.ex b/lib/phoenix_kit_web/live/modules.ex index ef11ccd3c..848af295c 100644 --- a/lib/phoenix_kit_web/live/modules.ex +++ b/lib/phoenix_kit_web/live/modules.ex @@ -10,6 +10,7 @@ defmodule PhoenixKitWeb.Live.Modules do alias PhoenixKit.Entities alias PhoenixKit.Module.Languages alias PhoenixKit.Modules.Maintenance + alias PhoenixKit.Modules.SEO alias PhoenixKit.Modules.Storage alias PhoenixKit.Pages alias PhoenixKit.ReferralCodes @@ -32,6 +33,7 @@ defmodule PhoenixKitWeb.Live.Modules do pages_enabled = Pages.enabled?() blogging_enabled = Blogging.enabled?() under_construction_config = Maintenance.get_config() + seo_config = SEO.get_config() storage_config = Storage.get_config() socket = @@ -62,6 +64,8 @@ defmodule PhoenixKitWeb.Live.Modules do |> assign(:storage_enabled, storage_config.module_enabled) |> assign(:storage_buckets_count, storage_config.buckets_count) |> assign(:storage_active_buckets_count, storage_config.active_buckets_count) + |> assign(:seo_module_enabled, seo_config.module_enabled) + |> assign(:seo_no_index_enabled, seo_config.no_index_enabled) |> assign(:current_locale, locale) {:ok, socket} @@ -206,6 +210,46 @@ defmodule PhoenixKitWeb.Live.Modules do end end + def handle_event("toggle_seo_module", _params, socket) do + new_enabled = !socket.assigns.seo_module_enabled + + result = + if new_enabled do + SEO.enable_module() + else + SEO.disable_module() + end + + case result do + {:ok, _setting} -> + seo_no_index_enabled = + if new_enabled do + SEO.no_index_enabled?() + else + false + end + + message = + if new_enabled do + "SEO module enabled - configure options in Settings โ†’ SEO" + else + "SEO module disabled and search directives reset" + end + + socket = + socket + |> assign(:seo_module_enabled, new_enabled) + |> assign(:seo_no_index_enabled, seo_no_index_enabled) + |> put_flash(:info, message) + + {:noreply, socket} + + {:error, _changeset} -> + socket = put_flash(socket, :error, "Failed to update SEO module") + {:noreply, socket} + end + end + def handle_event("toggle_blogging", _params, socket) do new_enabled = !socket.assigns.blogging_enabled diff --git a/lib/phoenix_kit_web/live/modules.html.heex b/lib/phoenix_kit_web/live/modules.html.heex index d782cad5d..9b7eaef0d 100644 --- a/lib/phoenix_kit_web/live/modules.html.heex +++ b/lib/phoenix_kit_web/live/modules.html.heex @@ -366,6 +366,50 @@ + <%!-- 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 %> + + {if @seo_no_index_enabled, do: "Noindex Active", else: "Indexing Allowed"} + + <% end %> + + + <:action_buttons> + <.link + navigate={ + PhoenixKit.Utils.Routes.path("/admin/settings/seo", locale: @current_locale || "en") + } + 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 + + + +
+ <%!-- Pages module remains disabled but code retained for future use --%> <%!-- Under Construction (Maintenance Mode) Module --%> diff --git a/lib/phoenix_kit_web/live/settings/seo.ex b/lib/phoenix_kit_web/live/settings/seo.ex new file mode 100644 index 000000000..e811ed6c0 --- /dev/null +++ b/lib/phoenix_kit_web/live/settings/seo.ex @@ -0,0 +1,84 @@ +defmodule PhoenixKitWeb.Live.Settings.SEO do + @moduledoc """ + SEO settings management LiveView for PhoenixKit. + + Provides a simple interface for global indexing directives (noindex/nofollow) + to keep staging or development deployments out of search engines. + """ + use PhoenixKitWeb, :live_view + use Gettext, backend: PhoenixKitWeb.Gettext + + alias PhoenixKit.Modules.SEO + alias PhoenixKit.Settings + alias PhoenixKit.Utils.Routes + + def mount(params, session, socket) do + locale = params["locale"] || socket.assigns[:current_locale] || "en" + Gettext.put_locale(PhoenixKitWeb.Gettext, locale) + Process.put(:phoenix_kit_current_locale, locale) + + if SEO.module_enabled?() do + project_title = Settings.get_setting("project_title", "PhoenixKit") + config = SEO.get_config() + + socket = + socket + |> assign(:page_title, "SEO Settings") + |> assign(:project_title, project_title) + |> assign(:current_locale, locale) + |> assign(:current_path, get_current_path(locale)) + |> assign(:no_index_enabled, config.no_index_enabled) + + {:ok, socket} + else + socket = + socket + |> put_flash( + :error, + gettext("SEO module is disabled. Enable it from the Modules page to configure settings.") + ) + |> redirect(to: Routes.path("/admin/modules", locale: locale)) + + {:ok, socket} + end + end + + def handle_event("toggle_no_index", _params, socket) do + new_value = !socket.assigns.no_index_enabled + + result = + if new_value do + SEO.enable_no_index() + else + SEO.disable_no_index() + end + + case result do + {:ok, _setting} -> + message = + if new_value do + gettext("Noindex/nofollow enabled. Search engines will not index this site.") + else + gettext("Noindex/nofollow disabled. Site can be indexed again.") + end + + socket = + socket + |> assign(:no_index_enabled, new_value) + |> put_flash(:info, message) + + {:noreply, socket} + + {:error, _changeset} -> + socket = + socket + |> put_flash(:error, gettext("Failed to update SEO settings")) + + {:noreply, socket} + end + end + + defp get_current_path(locale) do + Routes.path("/admin/settings/seo", locale: locale) + end +end diff --git a/lib/phoenix_kit_web/live/settings/seo.html.heex b/lib/phoenix_kit_web/live/settings/seo.html.heex new file mode 100644 index 000000000..608c67f80 --- /dev/null +++ b/lib/phoenix_kit_web/live/settings/seo.html.heex @@ -0,0 +1,69 @@ + +
+
+

Visibility

+

SEO Settings

+

+ Configure environment-wide search optimization defaults and metadata directives from a single place. +

+
+ +
+
+
+
+
+

+ Robots Directive +

+

Noindex · Nofollow

+

+ Adds + <meta name="robots" content="noindex,nofollow"> + + to every PhoenixKit layout, blocking crawlers from indexing or following links. +

+
+ +
+ +
+ <.icon + name={if @no_index_enabled, do: "hero-no-symbol", else: "hero-magnifying-glass-circle"} + class="w-6 h-6" + /> +
+ <%= if @no_index_enabled do %> + Search engines are instructed to skip this environment. Remove this before launch. + <% else %> + Crawlers are allowed to index the site. Enable the toggle to hide staging deployments. + <% end %> +
+
+
+
+ +
+
+
From f10718cd4d616a0ffe1d93a498542b28e45eb19f Mon Sep 17 00:00:00 2001 From: Max Don Date: Tue, 18 Nov 2025 23:53:26 +0200 Subject: [PATCH 02/14] Fix navigation hover state overriding active item styling * update admin_nav_item to use conditional hover classes * active items now show hover:bg-primary/90 instead of hover:bg-base-200 * prevents grey hover background from overriding blue active background --- lib/phoenix_kit_web/components/admin_nav.ex | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/phoenix_kit_web/components/admin_nav.ex b/lib/phoenix_kit_web/components/admin_nav.ex index 0d800fe16..71a58af35 100644 --- a/lib/phoenix_kit_web/components/admin_nav.ex +++ b/lib/phoenix_kit_web/components/admin_nav.ex @@ -56,11 +56,10 @@ defmodule PhoenixKitWeb.Components.AdminNav do <.link navigate={@href} class={[ - "flex items-center py-2 rounded-lg text-sm font-medium transition-colors", - "hover:bg-base-200 group", + "flex items-center py-2 rounded-lg text-sm font-medium transition-colors group", if(@active, - do: "bg-primary text-primary-content", - else: "text-base-content hover:text-primary" + do: "bg-primary text-primary-content hover:bg-primary/90", + else: "text-base-content hover:bg-base-200 hover:text-primary" ), if(@mobile, do: "w-full", else: ""), if(@nested, do: "pl-8 pr-3", else: "px-3") From d569761f011e6397818a13dc7416ad509cac810c Mon Sep 17 00:00:00 2001 From: Max Don Date: Wed, 19 Nov 2025 00:07:03 +0200 Subject: [PATCH 03/14] Add exact_match_only option to prevent parent nav highlighting on subtabs * introduce exact_match_only attribute to admin_nav_item component * update nav_item_active? to skip hierarchical matching when exact_match_only is true * apply exact_match_only={true} to blogging nav item * blogging parent now highlights only on /admin/blogging, not on /admin/blogging/{blog-slug} * individual blog subtabs continue to highlight correctly as nested items --- lib/phoenix_kit_web/components/admin_nav.ex | 21 ++++++++++++------- .../components/layout_wrapper.ex | 1 + 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/lib/phoenix_kit_web/components/admin_nav.ex b/lib/phoenix_kit_web/components/admin_nav.ex index 71a58af35..84a5d060c 100644 --- a/lib/phoenix_kit_web/components/admin_nav.ex +++ b/lib/phoenix_kit_web/components/admin_nav.ex @@ -43,12 +43,13 @@ defmodule PhoenixKitWeb.Components.AdminNav do attr(:mobile, :boolean, default: false) attr(:nested, :boolean, default: false) attr(:disable_active, :boolean, default: false) + attr(:exact_match_only, :boolean, default: false) def admin_nav_item(assigns) do active = if assigns.disable_active, do: false, - else: nav_item_active?(assigns.current_path, assigns.href, assigns.nested) + else: nav_item_active?(assigns.current_path, assigns.href, assigns.nested, assigns.exact_match_only) assigns = assign(assigns, :active, active) @@ -481,7 +482,7 @@ defmodule PhoenixKitWeb.Components.AdminNav do end # Helper function to determine if navigation item is active - defp nav_item_active?(current_path, href, nested) do + defp nav_item_active?(current_path, href, nested, exact_match_only \\ false) do current_parts = parse_admin_path(current_path) href_parts = parse_admin_path(href) @@ -489,11 +490,17 @@ defmodule PhoenixKitWeb.Components.AdminNav do if nested do exact_match?(current_parts, href_parts) or tab_match?(current_parts, href_parts) else - # For top-level items, use full hierarchical matching - exact_match?(current_parts, href_parts) or - tab_match?(current_parts, href_parts) or - parent_match?(current_parts, href_parts) or - hierarchical_match?(current_parts, href_parts) + # For top-level items with exact_match_only, skip hierarchical matching + base_matches = + exact_match?(current_parts, href_parts) or + tab_match?(current_parts, href_parts) or + parent_match?(current_parts, href_parts) + + if exact_match_only do + base_matches + else + base_matches or hierarchical_match?(current_parts, href_parts) + end end end diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index 72370c388..efae038b9 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -404,6 +404,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do icon="document" label="Blogging" current_path={@current_path || ""} + exact_match_only={true} /> <%= if submenu_open?(@current_path, ["/admin/blogging"]) do %> From 506a84b766f50df69e2f287a3126baf7a02fd15c Mon Sep 17 00:00:00 2001 From: Max Don Date: Wed, 19 Nov 2025 00:09:34 +0200 Subject: [PATCH 04/14] Add separate stat square for drafts on blogging dashboard * update blogging index grid from xl:grid-cols-4 to xl:grid-cols-5 * move drafts from stat description to its own dedicated stat square * improve visual hierarchy by giving drafts equal prominence with published posts --- .../live/modules/blogging/index.html.heex | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/phoenix_kit_web/live/modules/blogging/index.html.heex b/lib/phoenix_kit_web/live/modules/blogging/index.html.heex index 97a905483..2ec33b297 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/index.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/index.html.heex @@ -60,7 +60,7 @@
<% else %> -
+
{gettext("Total Blog Types")} @@ -80,8 +80,11 @@
{@dashboard_summary.published_posts}
-
- {gettext("Drafts: %{count}", count: @dashboard_summary.draft_posts)} +
+
+
{gettext("Drafts")}
+
+ {@dashboard_summary.draft_posts}
From e432a31ce248b7c1eb3dd40399316d4e3ac48d3a Mon Sep 17 00:00:00 2001 From: Max Don Date: Wed, 19 Nov 2025 00:20:05 +0200 Subject: [PATCH 05/14] Standardize blog storage mode badges to use consistent light grey color * change both slug-based and timestamp-based badges to use badge-ghost * remove conditional styling that made slug-based primary and timestamp-based ghost * apply consistent light grey appearance across blogging index and settings pages --- lib/phoenix_kit_web/live/modules/blogging/index.html.heex | 5 +---- lib/phoenix_kit_web/live/modules/blogging/settings.html.heex | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/phoenix_kit_web/live/modules/blogging/index.html.heex b/lib/phoenix_kit_web/live/modules/blogging/index.html.heex index 2ec33b297..60e7af8b7 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/index.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/index.html.heex @@ -123,10 +123,7 @@ <% end %>

- + <%= if insight.mode == "slug" do %> {gettext("Slug-based")} <% else %> diff --git a/lib/phoenix_kit_web/live/modules/blogging/settings.html.heex b/lib/phoenix_kit_web/live/modules/blogging/settings.html.heex index d051f28b2..81a8d6ab1 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/settings.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/settings.html.heex @@ -118,10 +118,7 @@ > {blog["name"]} - + <%= if blog["mode"] == "slug" do %> {gettext("Slug-based")} <% else %> From f56cd01d6bf8a5bf17ee977800a6d22569a9ad00 Mon Sep 17 00:00:00 2001 From: Max Don Date: Wed, 19 Nov 2025 02:19:20 +0200 Subject: [PATCH 06/14] Add timezone-aware formatting for blogging timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements proper timezone handling for blog post timestamps: - Store all times in UTC internally - Display times in logged-in user's timezone - Respect user's date/time format preferences from Settings - Allow users to publish at "5pm their time" without UTC math Performance optimizations: - Load date_time_settings once per mount using Settings.get_settings_cached/2 - Pass settings through call chain to cached formatter functions - Add cached timezone conversion helpers in PhoenixKit.Utils.Date: * shift_to_user_timezone_cached/3 * format_datetime_with_timezone_cached/4 * format_date_with_timezone_cached/4 * format_time_with_timezone_cached/4 - These use get_user_timezone_cached/2 to avoid per-row Settings queries Resilience improvements: - Add nil user fallback: user = current_user || %{user_timezone: nil} - Graceful handling during tests or unauthenticated access Files modified: - lib/phoenix_kit/utils/date.ex: Add cached timezone conversion helpers - lib/phoenix_kit_web/live/modules/blogging/index.ex: Load settings, use cached formatters - lib/phoenix_kit_web/live/modules/blogging/blog.ex: Load settings, use cached formatters - lib/phoenix_kit_web/live/modules/blogging/blog.html.heex: Pass user and settings to formatter ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/phoenix_kit/utils/date.ex | 72 ++++++++++++++++++- .../live/modules/blogging/blog.ex | 42 +++++++++-- .../live/modules/blogging/blog.html.heex | 2 +- .../live/modules/blogging/index.ex | 47 +++++++++--- 4 files changed, 143 insertions(+), 20 deletions(-) diff --git a/lib/phoenix_kit/utils/date.ex b/lib/phoenix_kit/utils/date.ex index db390682a..b3cf65438 100644 --- a/lib/phoenix_kit/utils/date.ex +++ b/lib/phoenix_kit/utils/date.ex @@ -477,6 +477,12 @@ defmodule PhoenixKit.Utils.Date do shift_to_timezone_offset(datetime, user_timezone_offset) end + # Private helper to shift datetime to user's timezone using cached settings + defp shift_to_user_timezone_cached(datetime, user, settings) do + user_timezone_offset = get_user_timezone_cached(user, settings) + shift_to_timezone_offset(datetime, user_timezone_offset) + end + # Private helper to apply timezone offset to datetime defp shift_to_timezone_offset(datetime, timezone_offset) do case Integer.parse(timezone_offset) do @@ -491,6 +497,66 @@ defmodule PhoenixKit.Utils.Date do end end + # Cached variant of format_datetime_with_timezone + defp format_datetime_with_timezone_cached(datetime, format, user, settings) do + case datetime do + nil -> + "Never" + + %NaiveDateTime{} = naive_dt -> + utc_datetime = DateTime.from_naive!(naive_dt, "Etc/UTC") + shifted_datetime = shift_to_user_timezone_cached(utc_datetime, user, settings) + format_datetime(shifted_datetime, format) + + %DateTime{} = dt -> + shifted_datetime = shift_to_user_timezone_cached(dt, user, settings) + format_datetime(shifted_datetime, format) + + _ -> + format_datetime(datetime, format) + end + end + + # Cached variant of format_date_with_timezone + defp format_date_with_timezone_cached(date, format, user, settings) do + case date do + %Date{} = d -> + format_date(d, format) + + %NaiveDateTime{} = naive_dt -> + utc_datetime = DateTime.from_naive!(naive_dt, "Etc/UTC") + shifted_datetime = shift_to_user_timezone_cached(utc_datetime, user, settings) + format_date(DateTime.to_date(shifted_datetime), format) + + %DateTime{} = dt -> + shifted_datetime = shift_to_user_timezone_cached(dt, user, settings) + format_date(DateTime.to_date(shifted_datetime), format) + + _ -> + format_date(date, format) + end + end + + # Cached variant of format_time_with_timezone + defp format_time_with_timezone_cached(time, format, user, settings) do + case time do + %Time{} = t -> + format_time(t, format) + + %NaiveDateTime{} = naive_dt -> + utc_datetime = DateTime.from_naive!(naive_dt, "Etc/UTC") + shifted_datetime = shift_to_user_timezone_cached(utc_datetime, user, settings) + format_time(DateTime.to_time(shifted_datetime), format) + + %DateTime{} = dt -> + shifted_datetime = shift_to_user_timezone_cached(dt, user, settings) + format_time(DateTime.to_time(shifted_datetime), format) + + _ -> + format_time(time, format) + end + end + @doc """ Gets the effective timezone for a user. @@ -583,7 +649,7 @@ defmodule PhoenixKit.Utils.Date do """ def format_datetime_with_user_timezone_cached(datetime, user, settings) do date_format = Map.get(settings, "date_format", "Y-m-d") - format_datetime_with_timezone(datetime, date_format, user) + format_datetime_with_timezone_cached(datetime, date_format, user, settings) end @doc """ @@ -598,7 +664,7 @@ defmodule PhoenixKit.Utils.Date do """ def format_date_with_user_timezone_cached(date, user, settings) do date_format = Map.get(settings, "date_format", "Y-m-d") - format_date_with_timezone(date, date_format, user) + format_date_with_timezone_cached(date, date_format, user, settings) end @doc """ @@ -613,7 +679,7 @@ defmodule PhoenixKit.Utils.Date do """ def format_time_with_user_timezone_cached(time, user, settings) do time_format = Map.get(settings, "time_format", "H:i") - format_time_with_timezone(time, time_format, user) + format_time_with_timezone_cached(time, time_format, user, settings) end @doc """ diff --git a/lib/phoenix_kit_web/live/modules/blogging/blog.ex b/lib/phoenix_kit_web/live/modules/blogging/blog.ex index d50f3d3a1..a0f777a91 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/blog.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/blog.ex @@ -7,6 +7,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Blog do alias PhoenixKit.Blogging.Renderer alias PhoenixKit.Settings + alias PhoenixKit.Utils.Date, as: UtilsDate alias PhoenixKit.Utils.Routes alias PhoenixKitWeb.BlogHTML alias PhoenixKitWeb.Live.Modules.Blogging @@ -19,6 +20,17 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Blog do Gettext.put_locale(PhoenixKitWeb.Gettext, locale) Process.put(:phoenix_kit_current_locale, locale) + # Load date/time format settings once for performance + date_time_settings = + Settings.get_settings_cached( + ["date_format", "time_format", "time_zone"], + %{ + "date_format" => "Y-m-d", + "time_format" => "H:i", + "time_zone" => "0" + } + ) + blogs = Blogging.list_blogs() current_blog = Enum.find(blogs, fn blog -> blog["slug"] == blog_slug end) posts = if blog_slug, do: Blogging.list_posts(blog_slug, locale), else: [] @@ -41,6 +53,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Blog do |> assign(:enabled_languages, Storage.enabled_language_codes()) |> assign(:posts, posts) |> assign(:endpoint_url, nil) + |> assign(:date_time_settings, date_time_settings) {:ok, redirect_if_missing(socket)} end @@ -178,17 +191,32 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Blog do defp redirect_if_missing(socket), do: socket - defp format_datetime(%{date: %Date{} = date, time: %Time{} = time}) do - date_str = Calendar.strftime(date, "%B %d, %Y") - time_str = Calendar.strftime(time, "%I:%M %p") + def format_datetime(%{date: %Date{} = date, time: %Time{} = time}, current_user, date_time_settings) do + # Fallback to dummy user if current_user is nil + user = current_user || %{user_timezone: nil} + + # Dates and times are already in the timezone they were created in + # Just format them with user preferences + date_str = UtilsDate.format_date_with_user_timezone_cached(date, user, date_time_settings) + time_str = UtilsDate.format_time_with_user_timezone_cached(time, user, date_time_settings) "#{date_str} #{gettext("at")} #{time_str}" end - defp format_datetime(%{metadata: %{published_at: published_at}}) when is_binary(published_at) do + def format_datetime(%{metadata: %{published_at: published_at}}, current_user, date_time_settings) when is_binary(published_at) do + # Fallback to dummy user if current_user is nil + user = current_user || %{user_timezone: nil} + case DateTime.from_iso8601(published_at) do {:ok, dt, _} -> - date_str = Calendar.strftime(dt, "%B %d, %Y") - time_str = Calendar.strftime(dt, "%I:%M %p") + # Convert DateTime to NaiveDateTime (assuming stored as UTC) + naive_dt = DateTime.to_naive(dt) + + # Format date part with timezone conversion + date_str = UtilsDate.format_date_with_user_timezone_cached(naive_dt, user, date_time_settings) + + # Format time part with timezone conversion + time_str = UtilsDate.format_time_with_user_timezone_cached(naive_dt, user, date_time_settings) + "#{date_str} #{gettext("at")} #{time_str}" _ -> @@ -196,7 +224,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Blog do end end - defp format_datetime(_post), do: gettext("Unsaved draft") + def format_datetime(_post, _user, _settings), do: gettext("Unsaved draft") defp extract_endpoint_url(uri) when is_binary(uri) do case URI.parse(uri) do diff --git a/lib/phoenix_kit_web/live/modules/blogging/blog.html.heex b/lib/phoenix_kit_web/live/modules/blogging/blog.html.heex index b331bcfe7..96e9272a2 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/blog.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/blog.html.heex @@ -87,7 +87,7 @@
- {format_datetime(post)} + {format_datetime(post, @phoenix_kit_current_user, @date_time_settings)}
<% blog_slug = post.blog || (@current_blog && @current_blog["slug"]) || @blog_slug || diff --git a/lib/phoenix_kit_web/live/modules/blogging/index.ex b/lib/phoenix_kit_web/live/modules/blogging/index.ex index dcb70d8f0..46c84d654 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/index.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/index.ex @@ -7,6 +7,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Index do use Gettext, backend: PhoenixKitWeb.Gettext alias PhoenixKit.Settings + alias PhoenixKit.Utils.Date, as: UtilsDate alias PhoenixKit.Utils.Routes alias PhoenixKitWeb.Live.Modules.Blogging alias PhoenixKitWeb.Live.Modules.Blogging.Storage @@ -16,7 +17,18 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Index do Gettext.put_locale(PhoenixKitWeb.Gettext, locale) Process.put(:phoenix_kit_current_locale, locale) - {blogs, insights, summary} = dashboard_snapshot(locale) + # Load date/time format settings once for performance + date_time_settings = + Settings.get_settings_cached( + ["date_format", "time_format", "time_zone"], + %{ + "date_format" => "Y-m-d", + "time_format" => "H:i", + "time_zone" => "0" + } + ) + + {blogs, insights, summary} = dashboard_snapshot(locale, socket.assigns[:phoenix_kit_current_user], date_time_settings) socket = socket @@ -30,12 +42,17 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Index do |> assign(:empty_state?, blogs == []) |> assign(:enabled_languages, Storage.enabled_language_codes()) |> assign(:endpoint_url, nil) + |> assign(:date_time_settings, date_time_settings) {:ok, socket} end def handle_params(_params, uri, socket) do - {blogs, insights, summary} = dashboard_snapshot(socket.assigns.current_locale) + {blogs, insights, summary} = dashboard_snapshot( + socket.assigns.current_locale, + socket.assigns[:phoenix_kit_current_user], + socket.assigns.date_time_settings + ) endpoint_url = extract_endpoint_url(uri) {:noreply, @@ -48,15 +65,15 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Index do )} end - defp dashboard_snapshot(locale) do + defp dashboard_snapshot(locale, current_user, date_time_settings) do blogs = Blogging.list_blogs() - insights = Enum.map(blogs, &build_blog_insight(&1, locale)) + insights = Enum.map(blogs, &build_blog_insight(&1, locale, current_user, date_time_settings)) summary = build_summary(blogs, insights) {blogs, insights, summary} end - defp build_blog_insight(blog, locale) do + defp build_blog_insight(blog, locale, current_user, date_time_settings) do posts = Blogging.list_posts(blog["slug"], locale) status_counts = Enum.frequencies_by(posts, &Map.get(&1.metadata, :status, "draft")) @@ -78,7 +95,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Index do archived_count: Map.get(status_counts, "archived", 0), languages: languages, last_published_at: latest_published_at, - last_published_at_text: format_datetime(latest_published_at) + last_published_at_text: format_datetime(latest_published_at, current_user, date_time_settings) } end @@ -132,10 +149,22 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Index do end end - defp format_datetime(nil), do: nil + defp format_datetime(nil, _user, _settings), do: nil + + defp format_datetime(%DateTime{} = datetime, current_user, date_time_settings) do + # Fallback to dummy user if current_user is nil + user = current_user || %{user_timezone: nil} + + # Convert DateTime to NaiveDateTime (assuming stored as UTC) + naive_dt = DateTime.to_naive(datetime) + + # Format date part with timezone conversion + date_str = UtilsDate.format_date_with_user_timezone_cached(naive_dt, user, date_time_settings) + + # Format time part with timezone conversion + time_str = UtilsDate.format_time_with_user_timezone_cached(naive_dt, user, date_time_settings) - defp format_datetime(%DateTime{} = datetime) do - Calendar.strftime(DateTime.truncate(datetime, :second), "%B %d, %Y ยท %H:%M UTC") + "#{date_str} #{time_str}" rescue _ -> nil end From 0121c51eef28b205d7e86b0c31a18dd2dcf375e6 Mon Sep 17 00:00:00 2001 From: Max Don Date: Wed, 19 Nov 2025 02:22:33 +0200 Subject: [PATCH 07/14] Update featured image file ID label to Phoenix Kit Media ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed label from "PhoenixKit Storage file ID" to "Phoenix Kit Media ID" in the blogging editor's advanced manual file ID input section for better clarity and consistency with product naming. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/phoenix_kit_web/live/modules/blogging/editor.html.heex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3fa6e7537..f0c4b6592 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex @@ -433,7 +433,7 @@ placeholder="018e3c4a-9f6b-7890-abcd-ef1234567890" />

- {gettext("Paste a PhoenixKit Storage file ID if you know it.")} + {gettext("Paste a Phoenix Kit Media ID if you know it.")}

From 6865ffc23aadfebf8141c8d0fdd109284d6aab16 Mon Sep 17 00:00:00 2001 From: Max Don Date: Wed, 19 Nov 2025 02:33:11 +0200 Subject: [PATCH 08/14] Add autosave functionality to blogging editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements automatic saving with debouncing to prevent accidental data loss: Features: - 2-second debounced autosave triggers on content or metadata changes - Proper timer management to prevent multiple simultaneous saves - Visual status indicators replacing manual save button: * "Saving..." badge (blue, with spinner) during autosave * "Unsaved changes" badge (yellow) when changes pending * "Saved" badge (green, with checkmark) when all saved - Silent autosave (no flash messages for automatic saves) - Manual save events still show confirmation messages - Browser exit protection remains for in-progress saves Technical implementation: - Added :is_autosaving and :autosave_timer assigns to track state - schedule_autosave/1 helper cancels old timers and schedules new ones - perform_save/1 extracted from save event for reuse in autosave - handle_info(:autosave) processes debounced autosave requests - Conditional flash messages based on is_autosaving flag Benefits: - Users no longer need to remember to click save - Work is automatically preserved during editing - Reduced cognitive load with clear visual feedback - Prevents data loss from browser crashes or accidental navigation ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../live/modules/blogging/editor.ex | 242 ++++++++++++------ .../live/modules/blogging/editor.html.heex | 27 +- 2 files changed, 174 insertions(+), 95 deletions(-) diff --git a/lib/phoenix_kit_web/live/modules/blogging/editor.ex b/lib/phoenix_kit_web/live/modules/blogging/editor.ex index 86d96355d..0bcba5353 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/editor.ex +++ b/lib/phoenix_kit_web/live/modules/blogging/editor.ex @@ -30,6 +30,8 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> assign(:show_media_selector, false) |> assign(:media_selection_mode, :single) |> assign(:media_selected_ids, MapSet.new()) + |> assign(:is_autosaving, false) + |> assign(:autosave_timer, nil) {:ok, socket} end @@ -196,13 +198,23 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do language = editor_language(socket.assigns) public_url = build_public_url(updated_post, language) - {:noreply, - socket - |> assign(:form, new_form) - |> assign(:has_pending_changes, has_changes) - |> assign(:public_url, public_url) - |> clear_flash() - |> push_event("changes-status", %{has_changes: has_changes})} + socket = + socket + |> assign(:form, new_form) + |> assign(:has_pending_changes, has_changes) + |> assign(:public_url, public_url) + |> clear_flash() + |> push_event("changes-status", %{has_changes: has_changes}) + + # Trigger debounced autosave if changes detected + socket = + if has_changes do + schedule_autosave(socket) + else + socket + end + + {:noreply, socket} end def handle_event("open_media_selector", _params, socket) do @@ -333,6 +345,14 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do push_event(acc, event, data) end) + # Trigger debounced autosave if changes detected + socket = + if has_changes do + schedule_autosave(socket) + else + socket + end + {:noreply, socket} end @@ -341,36 +361,7 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do end def handle_event("save", _params, socket) do - params = - socket.assigns.form - |> Map.take(["status", "published_at", "slug", "featured_image_id"]) - |> Map.put("content", socket.assigns.content) - - params = - case {socket.assigns.blog_mode, Map.get(params, "slug")} do - {"slug", slug} when is_binary(slug) and slug != "" -> - params - - {"slug", _} -> - Map.delete(params, "slug") - - _ -> - Map.delete(params, "slug") - end - - is_new_post = Map.get(socket.assigns, :is_new_post, false) - is_new_translation = Map.get(socket.assigns, :is_new_translation, false) - - cond do - is_new_post -> - create_new_post(socket, params) - - is_new_translation -> - create_new_translation(socket, params) - - true -> - update_existing_post(socket, params) - end + perform_save(socket) end def handle_event("noop", _params, socket), do: {:noreply, socket} @@ -468,6 +459,33 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do end @impl true + def handle_info(:autosave, socket) do + # Only autosave if there are pending changes + if socket.assigns.has_pending_changes do + socket = + socket + |> assign(:is_autosaving, true) + |> assign(:autosave_timer, nil) + |> push_event("autosave-status", %{saving: true}) + + # Perform the save + result = perform_save(socket) + + case result do + {:noreply, updated_socket} -> + {:noreply, + updated_socket + |> assign(:is_autosaving, false) + |> push_event("autosave-status", %{saving: false})} + + other -> + other + end + else + {:noreply, assign(socket, :autosave_timer, nil)} + end + end + def handle_info({:media_selected, file_ids}, socket) do # Handle the selected file IDs from the media selector modal file_id = List.first(file_ids) @@ -507,6 +525,50 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do |> assign(:inserting_image_component, false)} end + defp schedule_autosave(socket) do + # Cancel existing timer if any + if socket.assigns.autosave_timer do + Process.cancel_timer(socket.assigns.autosave_timer) + end + + # Schedule new autosave + timer_ref = Process.send_after(self(), :autosave, 2000) + assign(socket, :autosave_timer, timer_ref) + end + + defp perform_save(socket) do + params = + socket.assigns.form + |> Map.take(["status", "published_at", "slug", "featured_image_id"]) + |> Map.put("content", socket.assigns.content) + + params = + case {socket.assigns.blog_mode, Map.get(params, "slug")} do + {"slug", slug} when is_binary(slug) and slug != "" -> + params + + {"slug", _} -> + Map.delete(params, "slug") + + _ -> + Map.delete(params, "slug") + end + + is_new_post = Map.get(socket.assigns, :is_new_post, false) + is_new_translation = Map.get(socket.assigns, :is_new_translation, false) + + cond do + is_new_post -> + create_new_post(socket, params) + + is_new_translation -> + create_new_translation(socket, params) + + true -> + update_existing_post(socket, params) + end + end + defp create_new_post(socket, params) do scope = socket.assigns[:phoenix_kit_current_scope] @@ -528,24 +590,30 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do # Invalidate cache for newly created post invalidate_post_cache(socket.assigns.blog_slug, updated_post) - {:noreply, - socket - |> assign(:post, updated_post) - |> assign(:form, post_form(updated_post)) - |> assign(:content, updated_post.content) - |> assign(:available_languages, updated_post.available_languages) - |> assign(:has_pending_changes, false) - |> assign(:is_new_post, false) - |> assign(:blog_mode, socket.assigns.blog_mode) - |> push_event("changes-status", %{has_changes: false}) - |> put_flash(:info, gettext("Post created and saved")) - |> push_patch( - to: - Routes.path( - "/admin/blogging/#{socket.assigns.blog_slug}/edit?path=#{URI.encode(updated_post.path)}", - locale: socket.assigns.current_locale - ) - )} + flash_message = + if socket.assigns.is_autosaving, + do: nil, + else: gettext("Post created and saved") + + socket = + socket + |> assign(:post, updated_post) + |> assign(:form, post_form(updated_post)) + |> assign(:content, updated_post.content) + |> assign(:available_languages, updated_post.available_languages) + |> assign(:has_pending_changes, false) + |> assign(:is_new_post, false) + |> assign(:blog_mode, socket.assigns.blog_mode) + |> push_event("changes-status", %{has_changes: false}) + |> push_patch( + to: + Routes.path( + "/admin/blogging/#{socket.assigns.blog_slug}/edit?path=#{URI.encode(updated_post.path)}", + locale: socket.assigns.current_locale + ) + ) + + {:noreply, if(flash_message, do: put_flash(socket, :info, flash_message), else: socket)} {:error, :invalid_format} -> {:noreply, @@ -626,24 +694,30 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do # Invalidate cache for newly created translation invalidate_post_cache(socket.assigns.blog_slug, updated_post) - {:noreply, - socket - |> assign(:post, updated_post) - |> assign(:form, post_form(updated_post)) - |> assign(:content, updated_post.content) - |> assign(:available_languages, updated_post.available_languages) - |> assign(:has_pending_changes, false) - |> assign(:is_new_translation, false) - |> assign(:original_post_path, nil) - |> push_event("changes-status", %{has_changes: false}) - |> put_flash(:info, gettext("Translation created and saved")) - |> push_patch( - to: - Routes.path( - "/admin/blogging/#{socket.assigns.blog_slug}/edit?path=#{URI.encode(updated_post.path)}", - locale: socket.assigns.current_locale - ) - )} + flash_message = + if socket.assigns.is_autosaving, + do: nil, + else: gettext("Translation created and saved") + + socket = + socket + |> assign(:post, updated_post) + |> assign(:form, post_form(updated_post)) + |> assign(:content, updated_post.content) + |> assign(:available_languages, updated_post.available_languages) + |> assign(:has_pending_changes, false) + |> assign(:is_new_translation, false) + |> assign(:original_post_path, nil) + |> push_event("changes-status", %{has_changes: false}) + |> push_patch( + to: + Routes.path( + "/admin/blogging/#{socket.assigns.blog_slug}/edit?path=#{URI.encode(updated_post.path)}", + locale: socket.assigns.current_locale + ) + ) + + {:noreply, if(flash_message, do: put_flash(socket, :info, flash_message), else: socket)} {:error, :invalid_format} -> {:noreply, @@ -697,14 +771,20 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do # Invalidate cache for this post invalidate_post_cache(socket.assigns.blog_slug, post) - {:noreply, - socket - |> assign(:post, post) - |> assign(:form, post_form(post)) - |> assign(:content, post.content) - |> assign(:has_pending_changes, false) - |> push_event("changes-status", %{has_changes: false}) - |> put_flash(:info, gettext("Post saved"))} + flash_message = + if socket.assigns.is_autosaving, + do: nil, + else: gettext("Post saved") + + socket = + socket + |> assign(:post, post) + |> assign(:form, post_form(post)) + |> assign(:content, post.content) + |> assign(:has_pending_changes, false) + |> push_event("changes-status", %{has_changes: false}) + + {:noreply, if(flash_message, do: put_flash(socket, :info, flash_message), else: socket)} {:error, :invalid_format} -> {:noreply, 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 f0c4b6592..85416052b 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex @@ -487,21 +487,20 @@

{gettext("Content")}

- <%= if @has_pending_changes do %> - {gettext("Unsaved changes")} + <%= cond do %> + <% @is_autosaving -> %> + + + {gettext("Saving...")} + + <% @has_pending_changes -> %> + {gettext("Unsaved changes")} + <% true -> %> + + <.icon name="hero-check" class="w-3 h-3" /> + {gettext("Saved")} + <% end %> -
From 1e653f5ea3bc301765c12aafdd8ecb7cf7860875 Mon Sep 17 00:00:00 2001 From: Max Don Date: Wed, 19 Nov 2025 15:21:32 +0200 Subject: [PATCH 09/14] Fix blogging editor JavaScript for CSP-enabled environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves issues where component insertion and unsaved changes popup weren't working in environments with Content Security Policy enabled. Key fixes: - Add CSP nonce support to inline scripts for policy compliance - Add visible warning system if JavaScript features fail to initialize - Improve error handling with helpful messages for blocked scripts - Remove excessive console.log debugging statements - Better DOM initialization handling for LiveView updates - Add MutationObserver to manage warning visibility CSP Compliance: - Extract nonce from assigns (script_csp_nonce or csp_nonce) - Apply nonce attribute to all inline script tags - Scripts now work in strict CSP environments User Experience: - Warning banner hidden automatically when JS works - Shows helpful message if inline scripts blocked - Noscript fallback for fully disabled JavaScript - Init retry with timeout and error reporting Technical improvements: - Proper quote escaping in template strings - Cleaner event listener parameter naming - Warning element reference caching with validation - 20-attempt retry limit before showing error This fixes production/staging issues while maintaining local dev compatibility. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../live/modules/blogging/editor.html.heex | 169 +++++++++++++----- 1 file changed, 120 insertions(+), 49 deletions(-) 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 85416052b..845483180 100644 --- a/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex +++ b/lib/phoenix_kit_web/live/modules/blogging/editor.html.heex @@ -8,58 +8,122 @@ project_title={@project_title} current_locale={@current_locale} > - +