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
use Gettext, backend: PhoenixKitWeb.Gettext
require Logger
import PhoenixKitWeb.Components.Core.Flash, only: [flash_group: 1]
import PhoenixKitWeb.Components.Core.CookieConsent, only: [cookie_consent: 1]
import PhoenixKitWeb.Components.AdminNav
import PhoenixKitWeb.Components.Dashboard.AdminSidebar, only: [admin_sidebar: 1]
alias Phoenix.HTML
alias PhoenixKit.Config
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Modules.Languages.DialectMapper
alias PhoenixKit.Modules.Legal
alias PhoenixKit.Modules.SEO
alias PhoenixKit.ThemeConfig
alias PhoenixKit.Users.Auth.Scope
alias PhoenixKit.Utils.PhoenixVersion
@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: nil
attr :current_locale, :string, default: nil
attr :from_layout, :boolean, default: false
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
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.
# 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}(-[A-Z]{2})?(\/.*)?$/, 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.
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,
current_path: assigns[:current_path],
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]
}
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) --%>
<%!-- Logo --%>
<%!-- Project title and Admin label grouped together --%>
<%!-- 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)
# 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 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} />
{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.Settings.get_project_title()} Admin"}>
{assigns[:page_title] || "Admin"}
<%= if assigns[:seo_no_index] do %>
<% end %>
<%!-- Admin pages without parent headers --%>
<.flash_group flash={@flash} />
{render_slot(@inner_block)}
<%!-- Cookie Consent Widget --%>
<%= if Legal.consent_widget_enabled?() do %>
<% config = Legal.get_consent_widget_config() %>
<.cookie_consent
frameworks={config.frameworks}
consent_mode={config.consent_mode}
icon_position={config.icon_position}
policy_version={config.policy_version}
cookie_policy_url={config.cookie_policy_url}
privacy_policy_url={config.privacy_policy_url}
google_consent_mode={config.google_consent_mode}
/>
<% 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"""
{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())
|> 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+ - 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
# 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