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 alias PhoenixKitWeb.Users.Auth @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 :page_section, :string, default: nil, doc: "Optional breadcrumb segment rendered between \"Admin Panel\" and `page_title` (e.g. \"Users\" on a user detail page). Desktop only — collapses along with the rest of the breadcrumb prefix on mobile." attr :page_section_path, :string, default: nil, doc: "Prefixed path (via `PhoenixKit.Utils.Routes.path/1`) the `page_section` crumb links to. Renders as plain text when omitted." attr :page_action, :map, default: nil, doc: "Optional compact action button rendered right after the breadcrumb title: `%{icon: \"hero-plus\", label: \"New template\", navigate: path}`. Lets a page keep its primary create action without spending an in-content header row. `label` becomes the tooltip/aria-label; `icon` defaults to hero-plus. Navigation only — for a `phx-click` action (or anything needing `phx-target`), use the `:action` slot instead. ⚠️ Plugin LiveViews rendered through the admin layout can only use this map: the layout threads it as an assign, and a slot cannot travel that way." 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 :action, doc: """ The same compact action button, for pages whose primary action is not a navigation — a `phx-click`, a `JS` command, anything needing `phx-target`. The map attribute cannot express those and cannot address a LiveComponent. Takes render priority over the `page_action` attribute. Content is wrapped in the same chip shell, and the contract is **one compact control**: a multi-action toolbar belongs in the page body, not the breadcrumb bar. ⚠️ Only reaches views calling `app_layout/1` directly. Plugin LiveViews render through `layouts/admin.html.heex`, which threads `page_action` as an assign — slots do not travel through assigns — so those keep the map. <:action> """ 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 # Extracted so `wrap_inner_block_with_admin_nav_if_needed/1` stays under the # complexity ceiling — it is a plain projection of the caller's assigns onto # the keys the admin chrome template reads. defp admin_template_assigns(assigns, original_inner_block) do %{ 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], page_section: assigns[:page_section], page_section_path: assigns[:page_section_path], page_action: assigns[:page_action], # The slot travels here as an ordinary key; `assigns[:action]` is # `nil` for every caller that does not pass one, and the render # compares against `[]`. action: assigns[:action] || [], 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], # Whether this visitor gets a navigation sidebar at all. # # `Scope.can_access_admin_area?/1` is the gate `:phoenix_kit_ensure_admin` # applies before anything else, so failing it means every `/admin` # destination redirects — the sidebar would render an empty 16rem column # and a burger button opening an empty drawer. `/admin` itself is the # guaranteed landing for EVERY authenticated user, so that case is now # reachable: a visitor with no permissions gets the welcome page, the # header (theme, language, their own account menu, sign-out) and no # navigation. `AdminSidebar` applies the same gate to its own entries — # this one collapses the chrome around them. show_admin_nav: Scope.can_access_admin_area?(assigns[:phoenix_kit_current_scope]), # The bell's "View all" footer points at `/admin/notifications`. Reading # your own inbox is personal, not administrative — that is exactly what # `@personal_admin_views` says — but the page still lives under `/admin`, # so the admin-area gate bounces a visitor holding no permission. Ask the # destination's own gate rather than re-deriving one. can_open_inbox: Auth.can_access_admin_view?( assigns[:phoenix_kit_current_scope], PhoenixKitWeb.Live.Notifications.Inbox ), 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 } end 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 assigns = admin_template_assigns(assigns, original_inner_block) ~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. Dropped entirely for a visitor with no admin rights. `/admin` is the landing EVERY authenticated user can reach, so this shell now renders for people who are not operators — and telling someone with no sidebar, no subtitle and no operator content that they are in the "Admin Panel" is the one claim on the page that would be false. What remains is the ordinary breadcrumb the markup already builds: project title / page title. Omission rather than a replacement label, because a new msgid would ship untranslated in every shipped locale while this reuses strings that are already there. `show_admin_nav` is the same gate the sidebar and the burger button use, so an operator's header is byte-identical to before. --%> {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} <%!-- Slot wins when given; the map is the shorthand for the navigate-only case and the only thing plugin LiveViews can reach. Same chip shell either way, so the header bar cannot grow a toolbar. --%> {render_slot(@action)} <.link :if={@action == [] and @page_action} navigate={@page_action[:navigate]} class="btn btn-xs btn-primary btn-circle shrink-0" title={@page_action[:label]} aria-label={@page_action[:label]} >
<%!-- 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], "can_open_inbox" => @can_open_inbox } )} <% 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?} />
<%!-- Without `lg:drawer-open` AND without a `.drawer-side` child, daisyUI's `grid-auto-columns: max-content auto` leaves column one empty, so `.drawer-content` (grid-column-start: 2) spans the full width. --%>
<%!-- Main content --%>
<%!-- Page content from parent layout --%>
{render_slot(@original_inner_block)}
<%!-- Desktop/Mobile Sidebar. lg:[scrollbar-gutter:stable]: the sidebar is its own scroll container (the menu outgrows the viewport), and the drawer grid auto-sizes this column — without a reserved gutter its width flips ±15px (classic scrollbars) when a modal's page scroll lock changes ambient scroll state, shifting the whole content pane. Scoped to lg (the drawer-open column mode); the mobile overlay drawer needs no gutter. --%> <%!-- id + hook: AdminSidebarScroll keeps the menu's scroll position across navigations (a live redirect replaces this whole container; cross-live_session navigation is a full reload). The hook restores pre-paint; the save side lives in phoenix_kit.js as document-level listeners so no per-element cleanup is needed. --%>
<%!-- 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 # Standalone fallback (no `config :phoenix_kit, layout:` — core's own # dev/test, or a minimal host): render content only. The document shell # already comes from the router's `put_root_layout` (Layouts.root), so # rendering `` here nested a SECOND full # document inside the LiveView — and, worse, the spread carried # `app_layout`'s `attr :inner_content, default: nil` into a template that # renders `{@inner_content}`, so the actual page content was swallowed # entirely (empty login/register pages in standalone mode). The flash # group must render HERE, inside the LiveView's tree — the root layout's # copy is static after the dead render, so connected `put_flash` updates # would never display through it. @doc """ Rendering path for auth pages (login, register, reset, confirm, magic link, QR handoff, and the invite-only referral screen). Auth pages **do not** render inside the host's `Layouts.app`. That layout is where a `mix phx.new` app keeps its logo, framework version and off-site links, so wrapping sign-in in it put Phoenix Framework branding on the login page of every kit install — reported by more than one host. Admin already works this way (`render_admin_with_parent/1` never calls the host's `:app`); auth was the outlier. The host's **root** layout still applies, because `PhoenixKitWeb.Integration` never calls `put_root_layout`. That is where the document shell, assets and CSRF come from, and it is unaffected. A host that genuinely wants its own chrome on sign-in opts back in: config :phoenix_kit, auth_uses_host_layout: true ⚠️ Two things a host may notice when it does not: anything wired into the **app** layout rather than root — a cookie-consent banner, analytics, a theme toggle — stops appearing on auth pages only. Root-level wiring is unaffected. Stock `phx.new` puts assets and CSRF in root, so conventional hosts see only the branding disappear, which is the point. """ attr :flash, :map, required: true attr :phoenix_kit_current_scope, :any, default: nil attr :page_title, :string, default: nil attr :current_path, :string, default: nil attr :pk_pending_invitations, :list, default: [] slot :inner_block, required: true def auth_layout(assigns) do if auth_uses_host_layout?() do ~H""" <.app_layout flash={@flash} phoenix_kit_current_scope={@phoenix_kit_current_scope} page_title={@page_title} current_path={@current_path} > {render_slot(@inner_block)} """ else # `min-h-dvh` here so the auth background has a definite containing block # to fill: the wrapper inside uses `min-h-full`, which is a percentage and # therefore cannot overflow it. `dvh` rather than `vh` because on mobile # `vh` is the LARGE viewport, which puts the bottom of the card behind the # URL bar. # # Flash renders HERE, inside the LiveView tree — a copy in the root layout # freezes at its dead-render value (see `render_with_phoenix_kit_layout/1`). ~H""" <.flash_group flash={@flash} /> <.invitation_banners invitations={@pk_pending_invitations} />
{render_slot(@inner_block)}
""" end end defp auth_uses_host_layout? do PhoenixKit.Config.get_boolean(:auth_uses_host_layout, false) end 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""" <.flash_group flash={@flash} /> <.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 # 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