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 --%>
<%!-- 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