Packages

phoenix_kit

1.7.78
1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web components layout_wrapper.ex
Raw

lib/phoenix_kit_web/components/layout_wrapper.ex

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+) --%>
<PhoenixKitWeb.Components.LayoutWrapper.app_layout flash={@flash}>
<%!-- content --%>
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>
## 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.Core.PhoenixKitGlobals
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
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: %{}
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,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,
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 --%>
<%!-- Globals needed here for render_admin_with_parent path where parent layout may not set them --%>
<.phoenix_kit_globals />
<style data-phoenix-kit-themes>
<%= HTML.raw(ThemeConfig.custom_theme_css()) %>
</style>
<style>
/* Custom sidebar control for desktop - override lg:drawer-open grid layout when closed */
@media (min-width: 1024px) {
/* Override the grid to collapse sidebar column when closed */
#admin-drawer.sidebar-closed {
grid-template-columns: 0 1fr !important;
transition: grid-template-columns 300ms ease-in-out;
}
#admin-drawer.sidebar-closed .drawer-side {
transform: translateX(-16rem); /* -256px (w-64) */
transition: transform 300ms ease-in-out;
overflow: hidden;
}
#admin-drawer:not(.sidebar-closed) {
transition: grid-template-columns 300ms ease-in-out;
}
#admin-drawer:not(.sidebar-closed).drawer.lg\:drawer-open .drawer-side {
transform: translateX(0);
transition: transform 300ms ease-in-out;
}
}
</style>
<%!-- Top Bar Navbar (always visible, spans full width) --%>
<header class="bg-base-100 shadow-sm border-b border-base-300 fixed top-0 left-0 right-0 z-50">
<div class="flex items-center justify-between h-16 px-4">
<%!-- Left: Burger Menu, Logo and Title --%>
<div class="flex items-center gap-3">
<%!-- Burger Menu Button (Far left) --%>
<label for="admin-mobile-menu" class="btn btn-square btn-primary drawer-button p-0">
<PhoenixKitWeb.Components.Core.Icons.icon_menu />
</label>
<%!-- Logo --%>
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<PhoenixKitWeb.Components.Core.Icons.icon_shield />
</div>
<%!-- Project title and Admin label grouped together --%>
<div class="flex items-center gap-1 min-w-0">
<.link
href="/"
class="font-bold text-base-content hover:opacity-80 transition-opacity hidden sm:inline truncate"
>
{@project_title}
</.link>
<span class="font-bold text-base-content shrink-0">{gettext("Admin")}</span>
</div>
</div>
<%!-- Right: Theme Switcher, Language Dropdown, and User Dropdown --%>
<div class="flex items-center gap-3">
<.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}
/>
</div>
</div>
</header>
<div id="admin-drawer" class="drawer lg:drawer-open">
<input id="admin-mobile-menu" type="checkbox" class="drawer-toggle" />
<%!-- Main content --%>
<div class="drawer-content flex min-h-screen flex-col bg-base-100 transition-colors pt-16">
<%!-- Page content from parent layout --%>
<div class="flex-1">
{render_slot(@original_inner_block)}
</div>
</div>
<%!-- Desktop/Mobile Sidebar --%>
<div class="drawer-side">
<label for="admin-mobile-menu" class="drawer-overlay lg:hidden"></label>
<aside class="min-h-full w-64 bg-base-100 shadow-lg border-r border-base-300 flex flex-col pt-16">
<%!-- Navigation (fills available space) --%>
<div class="px-4 py-6 flex-1">
<.admin_sidebar
current_path={@current_path || ""}
scope={@scope}
locale={@current_locale_base}
/>
</div>
</aside>
</div>
</div>
<%!-- Auto-close mobile drawer on navigation --%>
<script>
// Mobile drawer and burger menu navigation
document.addEventListener('DOMContentLoaded', function() {
const drawerToggle = document.getElementById('admin-mobile-menu');
const adminDrawer = document.getElementById('admin-drawer');
const burgerMenuButton = document.querySelector('label[for="admin-mobile-menu"]');
// Close mobile drawer on navigation
const mainNavLinks = document.querySelectorAll('.drawer-side a');
mainNavLinks.forEach(link => {
link.addEventListener('click', () => {
if (drawerToggle && window.innerWidth < 1024) {
drawerToggle.checked = false;
}
});
});
// Handle burger menu toggle for desktop
if (burgerMenuButton && adminDrawer) {
burgerMenuButton.addEventListener('click', () => {
// On desktop (>= 1024px), toggle the sidebar-closed class
if (window.innerWidth >= 1024) {
adminDrawer.classList.toggle('sidebar-closed');
}
// On mobile, default checkbox behavior handles it
});
}
});
// Theme configuration and controller
const themeBaseMap = <%= ThemeConfig.base_map() |> Phoenix.json_library().encode!() |> Phoenix.HTML.raw() %>;
const themeLabels = <%= ThemeConfig.label_map() |> Phoenix.json_library().encode!() |> Phoenix.HTML.raw() %>;
// Admin theme controller for PhoenixKit with animated slider
const adminThemeController = {
init() {
// Safely query for dropdown controllers with null checks
const dropdownContainers = document.querySelectorAll('[data-theme-dropdown]');
this.dropdownControllers = Array.from(dropdownContainers).map((container) => ({
container,
button: container.querySelector('[data-theme-toggle]'),
panel: container.querySelector('[data-theme-dropdown-panel]'),
label: container.querySelector('[data-theme-current-label]')
}));
this.registerDropdownAccessibility();
this.systemMediaQuery =
typeof window.matchMedia === 'function'
? window.matchMedia('(prefers-color-scheme: dark)')
: null;
if (this.systemMediaQuery) {
this.systemMediaQuery.addEventListener('change', () => {
if ((localStorage.getItem('phx:theme') || 'system') === 'system') {
this.applyThemeAttributes('system');
}
});
}
const savedTheme = localStorage.getItem('phx:theme') || 'system';
this.setTheme(savedTheme);
this.setupListeners();
},
setTheme(theme) {
const resolvedTheme = this.applyThemeAttributes(theme, themeBaseMap);
if (theme === 'system') {
localStorage.removeItem('phx:theme');
} else {
localStorage.setItem('phx:theme', theme);
}
if (this.dropdownControllers?.length) {
this.dropdownControllers.forEach((entry) => {
if (entry.label) {
entry.label.textContent = themeLabels[theme] || this.toTitle(theme);
}
this.setDropdownState(entry, false);
});
}
// Update active state for all theme buttons
const themeButtons = document.querySelectorAll('[data-theme-target]');
themeButtons.forEach((btn) => {
const targets = (btn.dataset.themeTarget || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const isActive = targets.includes(theme);
if (btn.dataset.themeRole === 'dropdown-option') {
btn.classList.toggle('bg-base-200', isActive);
btn.classList.toggle('ring-2', isActive);
btn.classList.toggle('ring-primary/70', isActive);
btn.setAttribute('aria-selected', String(isActive));
btn
.querySelectorAll('[data-theme-active-indicator]')
.forEach((icon) => {
icon.classList.toggle('opacity-100', isActive);
icon.classList.toggle('scale-100', isActive);
icon.classList.toggle('scale-75', !isActive);
});
} else if (btn.dataset.themeRole === 'slider-button') {
btn.classList.toggle('text-primary', isActive);
btn.setAttribute('aria-pressed', String(isActive));
}
});
// Notify global PhoenixKit theme listeners
// Dispatch from a fake element with data-phx-theme attribute for compatibility with parent app listeners
// The event bubbles up to window, allowing window-level listeners to work correctly
try {
const fakeTarget = document.createElement('div');
fakeTarget.dataset.phxTheme = theme;
const event = new CustomEvent('phx:set-theme', {
detail: { theme },
bubbles: true
});
fakeTarget.dispatchEvent(event);
} catch (error) {
console.warn('PhoenixKit admin theme controller: unable to dispatch phx:set-theme', error);
}
if (window.PhoenixKitTheme && typeof window.PhoenixKitTheme.setTheme === 'function') {
try {
window.PhoenixKitTheme.setTheme(theme);
} catch (error) {
console.warn('PhoenixKit admin theme controller: unable to sync PhoenixKitTheme', error);
}
}
},
setupListeners() {
// Listen to Phoenix LiveView theme events (both variants)
document.addEventListener('phx:set-admin-theme', (e) => {
if (e?.detail?.theme) {
this.setTheme(e.detail.theme);
}
});
// Also listen for phx:set-theme from theme_controller component
window.addEventListener('phx:set-theme', (e) => {
if (e?.detail?.theme) {
this.setTheme(e.detail.theme);
}
});
},
registerDropdownAccessibility() {
if (!this.dropdownControllers?.length) return;
this.dropdownControllers.forEach((entry) => {
this.setDropdownState(entry, false);
if (!entry.button || !entry.panel) return;
entry.button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const expanded = entry.button.getAttribute('aria-expanded') === 'true';
this.setDropdownState(entry, !expanded);
});
entry.panel.addEventListener('click', (event) => {
event.stopPropagation();
});
});
document.addEventListener('click', (event) => {
const clickedInside = this.dropdownControllers.some((entry) =>
entry.container?.contains(event.target)
);
if (!clickedInside) {
this.dropdownControllers.forEach((entry) => this.setDropdownState(entry, false));
}
});
},
toTitle(value) {
return value
.split('-')
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(' ');
},
setDropdownState(entry, isOpen) {
if (!entry?.button || !entry?.panel) return;
entry.button.setAttribute('aria-expanded', String(!!isOpen));
entry.panel.setAttribute('aria-hidden', String(!isOpen));
entry.panel.classList.toggle('pointer-events-auto', !!isOpen);
entry.panel.classList.toggle('pointer-events-none', !isOpen);
entry.panel.classList.toggle('opacity-100', !!isOpen);
entry.panel.classList.toggle('opacity-0', !isOpen);
entry.panel.classList.toggle('-translate-y-2', !isOpen);
entry.panel.classList.toggle('translate-y-0', !!isOpen);
},
applyThemeAttributes(theme, baseMap = {}) {
const resolvedTheme =
theme === 'system'
? this.systemMediaQuery && this.systemMediaQuery.matches
? 'phoenix-dark'
: 'phoenix-light'
: theme;
if (document.documentElement) {
document.documentElement.setAttribute('data-theme', resolvedTheme);
document.documentElement.dataset.theme = resolvedTheme;
document.documentElement.setAttribute(
'data-admin-theme-base',
theme === 'system' ? 'system' : baseMap[resolvedTheme] || resolvedTheme
);
}
if (document.body) {
document.body.setAttribute('data-theme', resolvedTheme);
document.body.dataset.theme = resolvedTheme;
document.body.setAttribute(
'data-admin-theme-base',
theme === 'system' ? 'system' : baseMap[resolvedTheme] || resolvedTheme
);
document.body.classList.add('bg-base-100', 'transition-colors');
}
return resolvedTheme;
}
};
// Always initialize after DOM is fully loaded to avoid race conditions
if (document.readyState === 'loading' || document.readyState === 'interactive') {
// DOM still loading, wait for DOMContentLoaded
document.addEventListener('DOMContentLoaded', () => {
adminThemeController.init();
});
} else {
// DOM already loaded (readyState === 'complete'), safe to init immediately
adminThemeController.init();
}
</script>
"""
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"""
<main class="min-h-screen bg-base-100 transition-colors">
<.flash_group flash={@flash} />
{render_slot(@inner_block)}
</main>
"""
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"""
<!DOCTYPE html>
<html
lang={@content_language || "en"}
data-theme="light"
data-admin-theme-base="system"
class="[scrollbar-gutter:stable]"
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />
<.live_title default={"#{assigns[:project_title] || PhoenixKit.Settings.get_project_title()} Admin"}>
{assigns[:page_title] || "Admin"}
</.live_title>
<%= if assigns[:seo_no_index] do %>
<meta name="robots" content="noindex,nofollow" />
<meta name="googlebot" content="noindex,nofollow" />
<% end %>
<link phx-track-static rel="stylesheet" href="/assets/css/app.css" />
<%!-- PhoenixKit Cookie Consent Widget Setup --%>
<.phoenix_kit_globals />
<script defer src={Routes.path("/assets/phoenix_kit_consent.js")}>
</script>
</head>
<body class="bg-base-100 antialiased transition-colors" data-admin-theme-base="system">
<%!-- Admin pages without parent headers --%>
<main class="min-h-screen bg-base-100 transition-colors">
<.flash_group flash={@flash} />
{render_slot(@inner_block)}
</main>
<%!-- 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 %>
</body>
</html>
"""
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"""
<PhoenixKitWeb.Layouts.root {prepare_phoenix_kit_assigns(assigns)}>
{render_slot(@inner_block)}
</PhoenixKitWeb.Layouts.root>
"""
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+ - 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