defmodule PhoenixKitWeb.Components.LayoutWrapper do @compile {:no_warn_undefined, [PhoenixKit.Modules.Legal, PhoenixKit.Modules.Legal.CookieConsent]} @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 use Gettext, backend: PhoenixKitWeb.Gettext require Logger import PhoenixKitWeb.Components.Core.Flash, only: [flash_group: 1] import PhoenixKitWeb.Components.Core.PhoenixKitFavicon import PhoenixKitWeb.Components.Core.PhoenixKitGlobals import PhoenixKitWeb.Components.AdminNav import PhoenixKitWeb.Components.Dashboard.AdminSidebar, only: [admin_sidebar: 1] import PhoenixKitWeb.Components.InvitationBanner, only: [invitation_banners: 1] alias Phoenix.HTML alias PhoenixKit.Config alias PhoenixKit.Modules.Languages alias PhoenixKit.Modules.Languages.DialectMapper alias PhoenixKit.Modules.SEO alias PhoenixKit.Modules.Storage.URLSigner alias PhoenixKit.ThemeConfig alias PhoenixKit.Users.Auth.Scope alias PhoenixKit.Utils.PhoenixVersion alias PhoenixKit.Utils.Routes @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: %{} # Parent LiveView socket — required only to embed the sticky # NotificationsBell (a nested LiveView) in the admin header. Callers # pass `socket={@socket}`; when absent the bell is simply not rendered. attr :socket, :any, default: nil attr :phoenix_kit_current_scope, :any, default: nil attr :phoenix_kit_current_user, :any, default: nil attr :page_title, :string, default: nil attr :page_subtitle, :string, default: nil attr :current_path, :string, default: nil attr :inner_content, :string, default: nil attr :project_title, :string, default: nil attr :current_locale, :string, default: nil attr :from_layout, :boolean, default: false attr :pk_pending_invitations, :list, default: [] attr :module_assigns, :map, default: %{}, doc: "Module-supplied host-consumable assigns. Each key in this map is merged into the assigns set passed to the parent layout (`Layouts.app`), so a host's custom layout can read e.g. `assigns[:phoenix_kit_publishing_translations]` from publishing, or any other module-defined key. Plain `conn.assigns` don't reach a function-component layout — only declared attrs do — so this single map attribute is how modules thread arbitrary host-consumable data through the boundary without core having to declare each one explicitly." slot :inner_block, required: false def app_layout(assigns) do # Guard against double-wrapping: when admin.html.heex layout auto-applies admin # chrome for plugin views, the LiveView's render/1 may also call app_layout. # # Only the layout's call (from_layout=true) checks the flag. The LiveView's # direct call always renders normally and sets the flag for the layout to detect. # This avoids the stale-flag bug: in connected mode only the LiveView re-renders # (not the layout), so an unchecked flag would incorrectly persist across events. if assigns[:from_layout] && Process.delete(:phoenix_kit_admin_chrome_rendered) do Logger.debug( "[LayoutWrapper] app_layout called twice in same render tree. " <> "Plugin LiveViews should not call LayoutWrapper.app_layout — " <> "the admin.html.heex layout handles admin chrome automatically. " <> "Remove the LayoutWrapper wrapper from your render/1 function." ) ~H"{render_slot(@inner_block)}" else app_layout_inner(assigns) end end defp app_layout_inner(assigns) do # Batch load all page settings in a single operation for optimal database performance assigns = assigns |> assign_new(:content_language, fn -> # Use the current locale from LiveView, falling back to content language setting # Extract base code from full dialect if necessary (e.g., "en-US" -> "en") case assigns[:current_locale] do nil -> PhoenixKit.Settings.get_content_language() locale when is_binary(locale) -> DialectMapper.extract_base(locale) _ -> PhoenixKit.Settings.get_content_language() end 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) # For admin pages, render simplified layout without parent headers if admin_page?(assigns) do if get_layout_config() do # Parent layout provides the HTML shell (head, assets, CSRF, etc.) render_admin_with_parent(assigns) else # Standalone: full HTML document for PhoenixKit without parent app render_admin_only_layout(assigns) end 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 # Must return a strict boolean: `needs_inner_block_conversion?/1` calls # `not has_inner_block?(...)`. When `app_layout` is reached with only an # `inner_content` (the legacy Phoenix 1.7 flow) and no `inner_block` key at # all, the old `assigns[:inner_block] && ...` short-circuited to `nil`, and # `not nil` raised ArgumentError. `not in [nil, []]` normalizes both "absent" # (nil) and "declared-but-empty slot" ([]) to `false`. defp has_inner_block?(assigns), do: assigns[:inner_block] not in [nil, []] defp convert_inner_content_to_block(assigns) do inner_content = assigns[:inner_content] inner_block = build_synthetic_inner_block(inner_content) # Use assign/3 (not Map.put) so `__changed__[:inner_block]` is force-marked. # Phoenix only force-marks `:inner_content` on a diff (renderer.ex), so a # host layout's `render_slot(@inner_block)` dynamic is guarded by # `changed_assign?(__changed__, :inner_block)` — which is false for a bare # Map.put — and would emit nil, freezing the page body after first paint on # connected updates. Mirrors the admin-nav path's `assign(..., :inner_block)`. assign(assigns, :inner_block, inner_block) end # Synthesize a one-entry slot whose body yields `inner_content`. `inner_content` # may be a `%Phoenix.LiveView.Rendered{}` (return it verbatim — it is already # renderable; `Phoenix.HTML.raw/1` has no struct clause and would raise # FunctionClauseError on it) or a binary from the legacy Phoenix 1.7- flow # (mark it safe via `raw/1`). defp build_synthetic_inner_block(inner_content) do body = case inner_content do %Phoenix.LiveView.Rendered{} = rendered -> rendered other -> Phoenix.HTML.raw(other) end [%{inner_block: fn _slot_assigns, _index -> body end}] end # Check if current page is an admin page that needs navigation. # Strips URL prefix first, then locale prefix, to handle paths like # /phoenix_kit/uk/admin/users where the locale sits between prefix and /admin. defp admin_page?(assigns) do case assigns[:current_path] do nil -> false path when is_binary(path) -> prefix = PhoenixKit.Config.get_url_prefix() normalized = if prefix == "/", do: path, else: String.replace_prefix(path, prefix, "") # Strip locale prefix (e.g., /uk/admin → /admin) for localized admin routes normalized = strip_locale_prefix(normalized) normalized == "/admin" or String.starts_with?(normalized, "/admin/") _ -> false end end defp strip_locale_prefix(path) do case Regex.run(~r/^\/[a-z]{2,3}(-[A-Za-z]{2,4})?(\/.*)?$/, path) do [_, _locale, rest] when is_binary(rest) -> rest [_, _locale] -> "/" _ -> path 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 # Mark that admin chrome is being rendered by this (LiveView) call. # The layout's call (from_layout=true) will detect this and short-circuit. # Only set the flag for non-layout calls (core views that call app_layout directly). # Plugin views never call app_layout, so the layout's own call should NOT set # the flag — otherwise it persists in the process dictionary and causes the # layout to incorrectly short-circuit on subsequent LiveView re-renders. unless assigns[:from_layout], do: Process.put(:phoenix_kit_admin_chrome_rendered, true) # 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, # Parent LiveView socket — only used to embed the sticky # NotificationsBell; nil when the caller didn't thread it # through (then the bell simply isn't rendered). socket: assigns[:socket], phoenix_kit_current_user: assigns[:phoenix_kit_current_user], current_path: assigns[:current_path], page_title: assigns[:page_title], page_subtitle: assigns[:page_subtitle], phoenix_kit_current_scope: assigns[:phoenix_kit_current_scope], project_title: assigns[:project_title] || PhoenixKit.Settings.get_project_title(), current_locale: assigns[:current_locale], current_locale_base: assigns[:current_locale] && DialectMapper.extract_base(assigns[:current_locale]), scope: assigns[:phoenix_kit_current_scope], phoenix_kit_session_accounts: (assigns[:phoenix_kit_current_scope] && assigns[:phoenix_kit_current_scope].multi_session_accounts) || [], phoenix_kit_multi_session_allowed?: (assigns[:phoenix_kit_current_scope] && assigns[:phoenix_kit_current_scope].multi_session_allowed?) || false, auth_logo_url: case PhoenixKit.Settings.get_logo_uuid() do uuid when is_binary(uuid) and uuid != "" -> URLSigner.signed_url(uuid, "medium") _ -> nil end } assigns = template_assigns ~H""" <%!-- PhoenixKit Admin Layout --%> <%!-- Globals + favicon needed here for render_admin_with_parent path where parent layout may not set them --%> <.phoenix_kit_globals /> <.phoenix_kit_favicon /> <%!-- Top Bar Navbar (always visible, spans full width) --%>
<%!-- Left: Burger Menu, Logo and Title --%>
<%!-- Burger Menu Button (Far left) --%> <%!-- Logo --%> <%= if @auth_logo_url do %> {@project_title} <% end %> <%!-- Project title and Admin label grouped together --%>
<.link href="/" class="font-bold text-base-content hover:opacity-80 transition-opacity hidden sm:inline truncate" > {@project_title} <%!-- On mobile, when a page has a title, hide the "Admin Panel /" prefix and show just the page title — the full breadcrumb is too wide and overlaps the right-side theme / notifications controls. --%> {gettext("Admin Panel")} <%!-- Current page breadcrumb: " / Page Title · subtitle". Pushed in via page_title / page_subtitle so pages can drop their own in-content header and reclaim the space. --%> {@page_title}
<%!-- Right: Theme Switcher, Notifications bell, User Dropdown --%>
<.admin_theme_controller mobile={true} /> <%!-- Notifications bell — a sticky nested LiveView, shown only when the socket is threaded through, the module is enabled, and there's a logged-in user. Sticky + a stable id so the bell keeps its PubSub subscription across admin navigation. --%> <% bell_user = assigns[:phoenix_kit_current_user] || (assigns[:phoenix_kit_current_scope] && assigns[:phoenix_kit_current_scope].user) %> <%= if @socket && bell_user && PhoenixKit.Notifications.enabled?() do %> {Phoenix.Component.live_render(@socket, PhoenixKitWeb.Live.NotificationsBell, id: "pk-notifications-bell", sticky: true, session: %{ "user_uuid" => bell_user.uuid, "locale" => assigns[:current_locale_base] } )} <% end %> <.admin_user_dropdown scope={@phoenix_kit_current_scope} current_path={@current_path} current_locale={@current_locale} accounts={@phoenix_kit_session_accounts} multi_session_allowed?={@phoenix_kit_multi_session_allowed?} />
<%!-- 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 # 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) # `app_layout` is the single owner of the host layout (the native `:layout` # is a passthrough — see `PhoenixKitWeb.__using__(:live_view)`), so this is # the one place the host layout is applied. apply_host_layout(assigns, module, function) end # Apply the configured host layout, giving it BOTH inner conventions so it # works whether it renders `{@inner_content}` (the documented contract) or # `render_slot(@inner_block)` (the Phoenix 1.8 idiom). `app_layout` is invoked # with an `inner_block` slot; `ensure_inner_content_from_block/1` derives a # lazy `@inner_content` from it when absent. Rescues a bad # `config :phoenix_kit, layout:` (renamed/removed function) to PhoenixKit's own # layout instead of 500-ing every page. Public for regression testing. @doc false def apply_host_layout(assigns, module, function) do assigns |> ensure_inner_content_from_block() |> then(&apply(module, function, [&1])) rescue UndefinedFunctionError -> render_with_phoenix_kit_layout(assigns) end # Derive `@inner_content` from the `inner_block` slot when the caller only # supplied a slot. Kept lazy (a `%Rendered{}`) so a host layout using # `{@inner_content}` still change-tracks correctly on connected updates. defp ensure_inner_content_from_block(assigns) do if has_inner_content?(assigns) do assigns else assign(assigns, :inner_content, render_inner_block(assigns)) end end defp render_inner_block(assigns) do ~H"{render_slot(@inner_block)}" 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 when a parent layout provides the HTML shell. # Content only — root layout (from put_root_layout) supplies head, assets, CSRF, etc. defp render_admin_with_parent(assigns) do assigns = wrap_inner_block_with_admin_nav_if_needed(assigns) ~H"""
<.flash_group flash={@flash} /> <.invitation_banners invitations={@pk_pending_invitations} /> {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""" <% default_tab = PhoenixKit.Settings.get_setting_cached("default_tab_title", "") %> <.live_title default={ if(default_tab != "", do: default_tab, else: "#{assigns[:project_title] || PhoenixKit.Settings.get_project_title()} Admin" ) }> {assigns[:page_title] || "Admin"} <.phoenix_kit_favicon /> <%= if assigns[:seo_no_index] do %> <% end %> <%!-- PhoenixKit Cookie Consent Widget Setup --%> <.phoenix_kit_globals /> <%= if Code.ensure_loaded?(PhoenixKit.Modules.Legal) do %> <% end %> <%!-- Admin pages without parent headers --%>
<.flash_group flash={@flash} /> <.invitation_banners invitations={@pk_pending_invitations} /> {render_slot(@inner_block)}
<%!-- Cookie Consent Widget --%> <%= if Code.ensure_loaded?(PhoenixKit.Modules.Legal) and PhoenixKit.Modules.Legal.consent_widget_enabled?() do %> <% config = PhoenixKit.Modules.Legal.get_consent_widget_config() %> <% end %> """ 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""" <.invitation_banners invitations={@pk_pending_invitations} /> {render_slot(@inner_block)} """ end # Prepare assigns for parent layout compatibility defp prepare_parent_layout_assigns(assigns) do # Flatten `:module_assigns` into the top-level assigns map FIRST so that # host layouts can read module-supplied keys directly (e.g. # `assigns[:phoenix_kit_publishing_translations]`). Existing top-level # keys win over module-supplied ones to prevent a module from # overwriting core-managed assigns like `:flash` or `:current_user`. module_assigns = assigns[:module_assigns] || %{} assigns = Enum.reduce(module_assigns, assigns, fn {key, value}, acc -> Map.put_new(acc, key, value) end) 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()) |> 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 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+ - respect explicit layout: config first, then fall back # to {layouts_module, :app}. The layout: config allows parent apps to # specify a different layout function (e.g., :full_width instead of :app). case Config.get(:layout, nil) do {module, function} when is_atom(module) and is_atom(function) -> {module, function} _ -> case Config.get(:layouts_module, nil) do nil -> nil module -> {module, :app} end 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 # 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 # Build URL with base code - expects base code directly (e.g., "en" not "en-US") # Used by admin language switcher where language["code"] is already the base code def build_locale_url(current_path, base_code) do # Get enabled codes for locale detection in path enabled_language_codes = Languages.get_enabled_language_codes() enabled_base_codes = Enum.map(enabled_language_codes, &DialectMapper.extract_base/1) # Remove PhoenixKit prefix if present (use dynamic config, not hardcoded) url_prefix = PhoenixKit.Config.get_url_prefix() prefix_to_remove = if url_prefix == "/", do: "", else: url_prefix normalized_path = String.replace_prefix(current_path || "", prefix_to_remove, "") # Remove existing locale prefix from path clean_path = case String.split(normalized_path, "/", parts: 3) do ["", potential_locale, rest] -> if potential_locale in enabled_language_codes or potential_locale in enabled_base_codes do "/" <> rest else normalized_path end ["", potential_locale] -> if potential_locale in enabled_language_codes or potential_locale in enabled_base_codes do "/" else normalized_path end _ -> normalized_path end # Build URL with base code url_prefix = PhoenixKit.Config.get_url_prefix() base_prefix = if url_prefix == "/", do: "", else: url_prefix "#{base_prefix}/#{base_code}#{clean_path}" end # Legacy function - kept for backward compatibility def generate_language_switch_url(current_path, new_locale) do base_code = DialectMapper.extract_base(new_locale) build_locale_url(current_path, base_code) end end