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/users/rate_limiter.ex b/lib/phoenix_kit/users/rate_limiter.ex index 3c3bc25c2..b8bc62d99 100644 --- a/lib/phoenix_kit/users/rate_limiter.ex +++ b/lib/phoenix_kit/users/rate_limiter.ex @@ -64,6 +64,8 @@ defmodule PhoenixKit.Users.RateLimiter do require Logger + alias PhoenixKit.Users.RateLimiter.Backend + @default_config [ # Login: 5 attempts per minute per email login_limit: 5, @@ -260,58 +262,32 @@ defmodule PhoenixKit.Users.RateLimiter do @doc """ Resets rate limit for a specific action and identifier. - This is useful for: - - Admin intervention (clearing rate limits for legitimate users) - - Testing purposes - - Post-successful authentication cleanup + **DEPRECATED:** Hammer 7.x removed `delete_buckets` with no replacement. + This function now returns an error as Backend.set/3 requires positive integers (cannot set to 0). + + Rate limits will naturally expire after their configured window period. - For login and registration, the identifier should already include the prefix (e.g., "email:user@example.com" or "ip:192.168.1.1"). - For magic_link and password_reset, use just the email. + ## Migration - Note: With Hammer 7.x, this resets the counter to 0 using the set/3 function. + - **For testing**: Use `Application.put_env` to disable rate limiting + - **For admin intervention**: Wait for the time window to expire + - **For immediate reset**: Restart the application (clears ETS tables) + + See: https://hexdocs.pm/hammer/upgrade-v7.html ## Examples iex> PhoenixKit.Users.RateLimiter.reset_rate_limit(:login, "email:user@example.com") - :ok - - iex> PhoenixKit.Users.RateLimiter.reset_rate_limit(:magic_link, "user@example.com") - :ok + {:error, :not_supported} """ - def reset_rate_limit(action, identifier) when is_atom(action) and is_binary(identifier) do - # Normalize email if identifier doesn't already have a prefix (email: or ip:) - identifier = - if action in [:magic_link, :password_reset] and not String.contains?(identifier, ":") do - normalize_email(identifier) - else - identifier - end - - config = get_config() - key = "auth:#{action}:#{identifier}" - - # Get the window for this action type - window = - case action do - :login -> Keyword.get(config, :login_window_ms) - :magic_link -> Keyword.get(config, :magic_link_window_ms) - :password_reset -> Keyword.get(config, :password_reset_window_ms) - :registration -> Keyword.get(config, :registration_window_ms) - end - - # Hammer 7.x: Use set/3 to reset the counter to 0 - case PhoenixKit.Users.RateLimiter.Backend.set(key, window, 0) do - :ok -> - Logger.info("PhoenixKit.RateLimiter: Reset rate limit for #{action}:#{identifier}") - :ok - - {:error, reason} -> - Logger.error( - "PhoenixKit.RateLimiter: Failed to reset rate limit for #{action}:#{identifier}: #{inspect(reason)}" - ) + @deprecated "Hammer 7.x removed delete_buckets. Rate limits expire after their time window." + def reset_rate_limit(_action, _identifier) do + Logger.warning( + "PhoenixKit.RateLimiter.reset_rate_limit/2 is deprecated. " <> + "Rate limits expire automatically after their configured time window." + ) - {:error, reason} - end + {:error, :not_supported} end @doc """ @@ -361,30 +337,21 @@ defmodule PhoenixKit.Users.RateLimiter do end # Hammer 7.x: Use get/2 to retrieve the current count - case PhoenixKit.Users.RateLimiter.Backend.get(key, window) do - {:ok, count} when is_integer(count) -> - max(0, limit - count) - - _ -> - # If bucket doesn't exist or error, return full limit - limit - end + # Backend.get/2 returns an integer directly (current count) + count = Backend.get(key, window) + max(0, limit - count) end # Private functions defp check_rate_limit(key, window_ms, limit) do - case PhoenixKit.Users.RateLimiter.Backend.hit(key, window_ms, limit) do + # Hammer 7.x: Backend.hit/3 returns {:allow, count} or {:deny, retry_after} + case Backend.hit(key, window_ms, limit) do {:allow, _count} -> :ok {:deny, _retry_after_ms} -> {:error, :rate_limit_exceeded} - - {:error, reason} -> - # Log error but allow request to proceed (fail open for availability) - Logger.error("PhoenixKit.RateLimiter: Hammer error for #{key}: #{inspect(reason)}") - :ok end end 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/components/admin_nav.ex b/lib/phoenix_kit_web/components/admin_nav.ex index a55a5c970..40b0b5955 100644 --- a/lib/phoenix_kit_web/components/admin_nav.ex +++ b/lib/phoenix_kit_web/components/admin_nav.ex @@ -43,12 +43,19 @@ 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) @@ -56,11 +63,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") @@ -117,6 +123,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" -> %> @@ -480,7 +488,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) do current_parts = parse_admin_path(current_path) href_parts = parse_admin_path(href) @@ -488,11 +496,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 9e21a8c12..efae038b9 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) @@ -402,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 %> @@ -435,7 +438,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 +499,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 +906,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 +942,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..adb3b448a 100644 --- a/lib/phoenix_kit_web/live/modules.html.heex +++ b/lib/phoenix_kit_web/live/modules.html.heex @@ -366,6 +366,49 @@ + <%!-- 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/modules/blogging/blog.ex b/lib/phoenix_kit_web/live/modules/blogging/blog.ex index d50f3d3a1..f9ce0793e 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,43 @@ 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 +235,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..beb3f6ef2 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,9 @@
- {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/editor.ex b/lib/phoenix_kit_web/live/modules/blogging/editor.ex index 86d96355d..2f51059b6 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,27 @@ 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 + {:noreply, updated_socket} = perform_save(socket) + + {:noreply, + updated_socket + |> assign(:is_autosaving, false) + |> push_event("autosave-status", %{saving: false})} + 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 +519,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] @@ -523,82 +579,15 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do case Blogging.create_post(socket.assigns.blog_slug, create_opts) do {:ok, new_post} -> - case Blogging.update_post(socket.assigns.blog_slug, new_post, params, %{scope: scope}) do - {:ok, updated_post} -> - # 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 - ) - )} - - {:error, :invalid_format} -> - {:noreply, - put_flash( - socket, - :error, - gettext( - "Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-post-title)" - ) - )} - - {:error, :reserved_language_code} -> - {:noreply, - put_flash( - socket, - :error, - gettext( - "This slug is reserved because it's a language code (like 'en', 'es', 'fr'). Please choose a different slug to avoid routing conflicts." - ) - )} - - {:error, :invalid_slug} -> - {:noreply, - put_flash( - socket, - :error, - gettext( - "Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-post-title)" - ) - )} - - {:error, :slug_already_exists} -> - {:noreply, put_flash(socket, :error, gettext("A post with that slug already exists"))} - - {:error, _reason} -> - {:noreply, put_flash(socket, :error, gettext("Failed to save post"))} - end - - {:error, :invalid_slug} -> - {:noreply, - put_flash( - socket, - :error, - gettext( - "Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-post-title)" - ) - )} - - {:error, :slug_already_exists} -> - {:noreply, put_flash(socket, :error, gettext("A post with that slug already exists"))} + handle_post_update_result( + socket, + Blogging.update_post(socket.assigns.blog_slug, new_post, params, %{scope: scope}), + gettext("Post created and saved"), + %{is_new_post: false} + ) - {:error, _reason} -> - {:noreply, put_flash(socket, :error, gettext("Failed to create post"))} + {:error, error} -> + handle_post_creation_error(socket, error, gettext("Failed to create post")) end end @@ -621,66 +610,12 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do socket.assigns.current_language ) do {:ok, new_post} -> - case Blogging.update_post(socket.assigns.blog_slug, new_post, params, %{scope: scope}) do - {:ok, updated_post} -> - # 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 - ) - )} - - {:error, :invalid_format} -> - {:noreply, - put_flash( - socket, - :error, - gettext( - "Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-post-title)" - ) - )} - - {:error, :reserved_language_code} -> - {:noreply, - put_flash( - socket, - :error, - gettext( - "This slug is reserved because it's a language code (like 'en', 'es', 'fr'). Please choose a different slug to avoid routing conflicts." - ) - )} - - {:error, :invalid_slug} -> - {:noreply, - put_flash( - socket, - :error, - gettext( - "Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-post-title)" - ) - )} - - {:error, :slug_already_exists} -> - {:noreply, put_flash(socket, :error, gettext("A post with that slug already exists"))} - - {:error, _reason} -> - {:noreply, put_flash(socket, :error, gettext("Failed to save translation"))} - end + handle_post_update_result( + socket, + Blogging.update_post(socket.assigns.blog_slug, new_post, params, %{scope: scope}), + gettext("Translation created and saved"), + %{is_new_translation: false, original_post_path: nil} + ) {:error, _reason} -> {:noreply, put_flash(socket, :error, gettext("Failed to create translation file"))} @@ -697,14 +632,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, @@ -744,6 +685,104 @@ defmodule PhoenixKitWeb.Live.Modules.Blogging.Editor do end end + # Helper function to handle post update results and reduce cyclomatic complexity + defp handle_post_update_result(socket, update_result, success_message, extra_assigns) do + case update_result do + {:ok, updated_post} -> + # Invalidate cache for the post + invalidate_post_cache(socket.assigns.blog_slug, updated_post) + + flash_message = + if socket.assigns.is_autosaving, + do: nil, + else: success_message + + 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(extra_assigns) + |> 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, error} -> + handle_post_update_error(socket, error) + end + end + + # Helper function to handle post update errors + defp handle_post_update_error(socket, :invalid_format) do + {:noreply, + put_flash( + socket, + :error, + gettext( + "Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-post-title)" + ) + )} + end + + defp handle_post_update_error(socket, :reserved_language_code) do + {:noreply, + put_flash( + socket, + :error, + gettext( + "This slug is reserved because it's a language code (like 'en', 'es', 'fr'). Please choose a different slug to avoid routing conflicts." + ) + )} + end + + defp handle_post_update_error(socket, :invalid_slug) do + {:noreply, + put_flash( + socket, + :error, + gettext( + "Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-post-title)" + ) + )} + end + + defp handle_post_update_error(socket, :slug_already_exists) do + {:noreply, put_flash(socket, :error, gettext("A post with that slug already exists"))} + end + + defp handle_post_update_error(socket, _reason) do + {:noreply, put_flash(socket, :error, gettext("Failed to save post"))} + end + + # Helper function to handle post creation errors + defp handle_post_creation_error(socket, :invalid_slug, _fallback_message) do + {:noreply, + put_flash( + socket, + :error, + gettext( + "Invalid slug format. Please use only lowercase letters, numbers, and hyphens (e.g. my-post-title)" + ) + )} + end + + defp handle_post_creation_error(socket, :slug_already_exists, _fallback_message) do + {:noreply, put_flash(socket, :error, gettext("A post with that slug already exists"))} + end + + defp handle_post_creation_error(socket, _reason, fallback_message) do + {:noreply, put_flash(socket, :error, fallback_message)} + end + defp post_form(post) do base = %{ "status" => post.metadata.status || "draft", 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..0f44f055e 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,128 @@ project_title={@project_title} current_locale={@current_locale} > - +