defmodule PhoenixKitWeb.Components.LayoutWrapper do @moduledoc """ Dynamic layout wrapper component for Phoenix v1.7- and v1.8+ compatibility. This component automatically detects the Phoenix version and layout configuration to provide seamless integration with parent applications while maintaining backward compatibility. ## Usage Replace direct layout calls with the wrapper: <%!-- OLD (Phoenix v1.7-) --%> <%!-- Templates relied on router-level layout config --%> <%!-- NEW (Phoenix v1.8+) --%> <%!-- content --%> ## Configuration Configure parent layout in config.exs: config :phoenix_kit, layout: {MyAppWeb.Layouts, :app} """ use Phoenix.Component use PhoenixKitWeb, :verified_routes import PhoenixKitWeb.Components.Core.Flash, only: [flash_group: 1] import PhoenixKitWeb.Components.AdminNav alias Phoenix.HTML alias PhoenixKit.Config alias PhoenixKit.Module.Languages alias PhoenixKit.ThemeConfig alias PhoenixKit.Users.Auth.Scope alias PhoenixKit.Utils.PhoenixVersion alias PhoenixKit.Utils.Routes alias PhoenixKitWeb.Live.Modules.Blogging @doc """ Renders content with the appropriate layout based on configuration and Phoenix version. Automatically handles: - Phoenix v1.8+ function component layouts - Phoenix v1.7- legacy layout configuration - Fallback to PhoenixKit layouts when no parent configured - Parent layout compatibility with PhoenixKit assigns ## Attributes - `flash` - Flash messages (required) - `phoenix_kit_current_scope` - Current authentication scope (optional) - `phoenix_kit_current_user` - Current user (optional, for backwards compatibility) ## Inner Block - `inner_block` - Content to render within the layout """ attr :flash, :map, default: %{} attr :phoenix_kit_current_scope, :any, default: nil attr :phoenix_kit_current_user, :any, default: nil attr :page_title, :string, default: nil attr :current_path, :string, default: nil attr :inner_content, :string, default: nil attr :project_title, :string, default: "PhoenixKit" attr :current_locale, :string, default: "en" slot :inner_block, required: false def app_layout(assigns) do # Batch load all page settings in a single operation for optimal database performance assigns = assigns |> assign_new(:content_language, fn -> PhoenixKit.Settings.get_content_language() end) |> assign_new(:blogging_blogs, fn -> load_blogging_blogs() end) # Handle both inner_content (Phoenix 1.7-) and inner_block (Phoenix 1.8+) assigns = normalize_content_assigns(assigns) # For admin pages, render simplified layout without parent headers if admin_page?(assigns) do render_admin_only_layout(assigns) else case get_layout_config() do {module, function} when is_atom(module) and is_atom(function) -> render_with_parent_layout(assigns, module, function) nil -> render_with_phoenix_kit_layout(assigns) end end end ## Private Implementation # Normalize content assigns to handle both inner_content and inner_block defp normalize_content_assigns(assigns) do if needs_inner_block_conversion?(assigns) do convert_inner_content_to_block(assigns) else assigns end end defp needs_inner_block_conversion?(assigns) do has_inner_content?(assigns) and not has_inner_block?(assigns) end defp has_inner_content?(assigns), do: assigns[:inner_content] != nil defp has_inner_block?(assigns), do: assigns[:inner_block] && assigns[:inner_block] != [] defp convert_inner_content_to_block(assigns) do inner_content = assigns[:inner_content] inner_block = build_synthetic_inner_block(inner_content) Map.put(assigns, :inner_block, inner_block) end defp build_synthetic_inner_block(inner_content) do [ %{ inner_block: fn _slot_assigns, _index -> Phoenix.HTML.raw(inner_content) end } ] end # Check if current page is an admin page that needs navigation defp admin_page?(assigns) do case assigns[:current_path] do nil -> false path when is_binary(path) -> String.contains?(path, "/admin") _ -> false end end # Wrap inner_block with admin navigation if needed defp wrap_inner_block_with_admin_nav_if_needed(assigns) do if admin_page?(assigns) do # Create new inner_block slot that wraps original content with admin navigation original_inner_block = assigns[:inner_block] new_inner_block = [ %{ inner_block: fn _slot_assigns, _index -> # Create template assigns with needed values template_assigns = %{ original_inner_block: original_inner_block, current_path: assigns[:current_path], phoenix_kit_current_scope: assigns[:phoenix_kit_current_scope], project_title: assigns[:project_title] || "PhoenixKit", current_locale: assigns[:current_locale] || "en", blogging_blogs: assigns[:blogging_blogs] || [] } assigns = template_assigns ~H""" <%!-- PhoenixKit Admin Layout following EZNews pattern --%> <%!-- Top Bar Navbar (always visible, spans full width) --%>
<%!-- Left: Burger Menu, Logo and Title --%>
<%!-- Burger Menu Button (Far left) --%>
{@project_title} Admin
<%!-- Right: Theme Switcher, Language Dropdown, and User Dropdown --%>
<.admin_theme_controller mobile={true} /> <.admin_language_dropdown current_path={@current_path} current_locale={@current_locale} /> <.admin_user_dropdown scope={@phoenix_kit_current_scope} current_path={@current_path} current_locale={@current_locale} />
<%!-- Main content --%>
<%!-- Page content from parent layout --%>
{render_slot(@original_inner_block)}
<%!-- Desktop/Mobile Sidebar --%>
<%!-- Auto-close mobile drawer on navigation --%> """ end } ] # Return assigns with new inner_block assign(assigns, :inner_block, new_inner_block) else # Not an admin page, return assigns unchanged assigns end end # Check if a submenu should be open based on current path defp submenu_open?(current_path, paths) when is_binary(current_path) do current_path |> remove_phoenix_kit_prefix() |> remove_locale_prefix() |> path_matches_any?(paths) end defp submenu_open?(_, _), do: false defp remove_phoenix_kit_prefix(path) do url_prefix = Config.get_url_prefix() if url_prefix == "/" do path else String.replace_prefix(path, url_prefix, "") end end defp remove_locale_prefix(path) do case String.split(path, "/", parts: 3) do ["", locale, rest] when locale != "" and rest != "" -> if looks_like_locale?(locale), do: "/" <> rest, else: path _ -> path end end defp looks_like_locale?(locale), do: String.length(locale) <= 3 defp path_matches_any?(normalized_path, paths) do Enum.any?(paths, &String.starts_with?(normalized_path, &1)) end # Render with parent application layout (Phoenix v1.8+ function component approach) defp render_with_parent_layout(assigns, module, function) do # Prepare assigns for parent layout compatibility assigns = prepare_parent_layout_assigns(assigns) # Dynamically call the parent layout function based on Phoenix version case PhoenixVersion.get_strategy() do :modern -> render_modern_parent_layout(assigns, module, function) :legacy -> render_legacy_parent_layout(assigns, module, function) end end # Phoenix v1.8+ approach - function components defp render_modern_parent_layout(assigns, module, function) do # Wrap inner content with admin navigation if needed assigns = wrap_inner_block_with_admin_nav_if_needed(assigns) # Use apply/3 to dynamically call the parent layout function apply(module, function, [assigns]) rescue UndefinedFunctionError -> # Fallback to PhoenixKit layout if parent function doesn't exist render_with_phoenix_kit_layout(assigns) end # Phoenix v1.7- approach - templates (legacy support) defp render_legacy_parent_layout(assigns, _module, _function) do # For legacy Phoenix, layouts are handled at router level # Wrap inner content with admin navigation if needed assigns = wrap_inner_block_with_admin_nav_if_needed(assigns) # Just render content without wrapper - layout comes from router ~H""" {render_slot(@inner_block)} """ end # Render admin pages with simplified layout (no parent headers) defp render_admin_only_layout(assigns) do # Wrap inner content with admin navigation assigns = wrap_inner_block_with_admin_nav_if_needed(assigns) ~H""" <.live_title default={"#{assigns[:project_title] || "PhoenixKit"} Admin"}> {assigns[:page_title] || "Admin"} <%!-- Admin pages without parent headers --%>
<.flash_group flash={@flash} /> {render_slot(@inner_block)}
""" end # Fallback to PhoenixKit's own layout defp render_with_phoenix_kit_layout(assigns) do # Wrap inner content with admin navigation if needed assigns = wrap_inner_block_with_admin_nav_if_needed(assigns) ~H""" {render_slot(@inner_block)} """ end # Prepare assigns for parent layout compatibility defp prepare_parent_layout_assigns(assigns) do assigns |> Map.put_new(:current_user, get_current_user_for_parent(assigns)) |> 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()) end # Prepare assigns specifically for PhoenixKit layout defp prepare_phoenix_kit_assigns(assigns) do assigns |> Map.put_new(:phoenix_kit_standalone, true) end # Extract current user from scope for parent layout compatibility defp get_current_user_for_parent(assigns) do case assigns[:phoenix_kit_current_scope] do nil -> assigns[:phoenix_kit_current_user] scope -> Scope.user(scope) end end # Get layout configuration from PhoenixKit.Config with Phoenix version compatibility defp get_layout_config do case Config.get(:phoenix_version_strategy, nil) do :modern -> # Phoenix v1.8+ - get layouts_module and assume :app function case Config.get(:layouts_module, nil) do nil -> nil module -> {module, :app} end :legacy -> # Phoenix v1.7- - use legacy layout config Config.get(:layout, nil) nil -> # Fallback - check for legacy layout config first Config.get(:layout, nil) end end # Get PhoenixKit version defp get_phoenix_kit_version do case Application.spec(:phoenix_kit) do nil -> "unknown" spec -> spec |> Keyword.get(:vsn, "unknown") |> to_string() end end # Load blogging blogs configuration with legacy migration support defp load_blogging_blogs do if Blogging.enabled?() do json_defaults = %{ "blogging_blogs" => nil, "blogging_categories" => %{"types" => []} } json_settings = PhoenixKit.Settings.get_json_settings_cached( ["blogging_blogs", "blogging_categories"], json_defaults ) extract_and_normalize_blogs(json_settings) else [] end end defp extract_and_normalize_blogs(json_settings) do case json_settings["blogging_blogs"] do %{"blogs" => blogs} when is_list(blogs) -> normalize_blogs(blogs) list when is_list(list) -> normalize_blogs(list) _ -> handle_legacy_blogging_categories(json_settings) end end defp handle_legacy_blogging_categories(json_settings) do legacy = case json_settings["blogging_categories"] do %{"types" => types} when is_list(types) -> types other when is_list(other) -> other _ -> [] end migrate_legacy_categories_if_present(legacy) normalize_blogs(legacy) end defp migrate_legacy_categories_if_present([]), do: :ok defp migrate_legacy_categories_if_present(legacy) do PhoenixKit.Settings.update_json_setting("blogging_blogs", %{"blogs" => legacy}) end # Normalize blogs list to ensure consistent structure defp normalize_blogs(blogs) do blogs |> Enum.map(&normalize_blog_keys/1) |> Enum.map(fn %{"mode" => mode} = blog when mode in ["timestamp", "slug"] -> blog blog -> Map.put(blog, "mode", "timestamp") end) end defp normalize_blog_keys(blog) when is_map(blog) do Enum.reduce(blog, %{}, fn {key, value}, acc when is_binary(key) -> Map.put(acc, key, value) {key, value}, acc when is_atom(key) -> Map.put(acc, Atom.to_string(key), value) {key, value}, acc -> Map.put(acc, to_string(key), value) end) end defp normalize_blog_keys(other), do: other # Language switcher component for admin sidebar attr :current_path, :string, required: true attr :current_locale, :string, default: "en" defp admin_language_switcher(assigns) do # Only show if languages are enabled and there are enabled languages if Languages.enabled?() do enabled_languages = Languages.get_enabled_languages() # Only show if there are multiple languages (more than current one) if length(enabled_languages) > 1 do current_language = Enum.find(enabled_languages, &(&1["code"] == assigns.current_locale)) || %{"code" => assigns.current_locale, "name" => String.upcase(assigns.current_locale)} other_languages = Enum.reject(enabled_languages, &(&1["code"] == assigns.current_locale)) assigns = assigns |> assign(:enabled_languages, enabled_languages) |> assign(:current_language, current_language) |> assign(:other_languages, other_languages) ~H""" """ else ~H"" end else ~H"" end end # Used in HEEX template - compiler cannot detect usage def get_language_flag(code) when is_binary(code) do case Languages.get_predefined_language(code) do %{flag: flag} -> flag nil -> "🌐" end end # Used in HEEX template - compiler cannot detect usage def generate_language_switch_url(current_path, new_locale) do # Get actual enabled language codes to properly detect locale prefixes enabled_language_codes = Languages.get_enabled_language_codes() # Remove PhoenixKit prefix if present normalized_path = String.replace_prefix(current_path || "", "/phoenix_kit", "") # Remove existing locale prefix only if it matches actual language codes clean_path = case String.split(normalized_path, "/", parts: 3) do ["", potential_locale, rest] -> if potential_locale in enabled_language_codes do "/" <> rest else normalized_path end _ -> normalized_path end # Build the new URL with the new locale prefix url_prefix = PhoenixKit.Config.get_url_prefix() base_prefix = if url_prefix == "/", do: "", else: url_prefix "#{base_prefix}/#{new_locale}#{clean_path}" end end