Packages

phoenix_kit

1.4.6
1.7.208 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
import PhoenixKitWeb.Components.Core.Flash, only: [flash_group: 1]
import PhoenixKitWeb.Components.AdminNav
alias Phoenix.HTML
alias PhoenixKit.Module.Languages
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: "PhoenixKit"
attr :current_locale, :string, default: "en"
slot :inner_block, required: false
def app_layout(assigns) do
# Ensure content_language is available in assigns
assigns =
assigns
|> assign_new(:content_language, fn ->
PhoenixKit.Settings.get_content_language()
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 we have inner_content but no inner_block, create inner_block from inner_content
if assigns[:inner_content] && (!assigns[:inner_block] || assigns[:inner_block] == []) do
inner_content = assigns[:inner_content]
# Create a synthetic inner_block slot
inner_block = [
%{
inner_block: fn _slot_assigns, _index ->
Phoenix.HTML.raw(inner_content)
end
}
]
Map.put(assigns, :inner_block, inner_block)
else
# If we have inner_block but no inner_content, leave as is
assigns
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"
}
assigns = template_assigns
~H"""
<%!-- PhoenixKit Admin Layout following EZNews pattern --%>
<style data-phoenix-kit-themes>
<%= HTML.raw(ThemeConfig.custom_theme_css()) %>
</style>
<style>
/* Custom sidebar control for desktop - override lg:drawer-open when closed */
@media (min-width: 1024px) {
#admin-drawer.sidebar-closed .drawer-side {
transform: translateX(-16rem); /* -256px (w-64) */
transition: transform 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>
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<PhoenixKitWeb.Components.Core.Icons.icon_shield />
</div>
<span class="font-bold text-base-content">{@project_title} Admin</span>
</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) --%>
<nav class="px-4 py-6 space-y-2 flex-1">
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/dashboard")}
icon="dashboard"
label="Dashboard"
current_path={@current_path || ""}
/>
<%!-- Users section with direct link and conditional submenu --%>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/users")}
icon="users"
label="Users"
current_path={@current_path || ""}
disable_active={true}
/>
<%= if submenu_open?(@current_path, ["/admin/users", "/admin/users/live_sessions", "/admin/users/sessions", "/admin/users/roles", "/admin/users/referral-codes"]) do %>
<%!-- Submenu items --%>
<div class="mt-1">
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/users")}
icon="users"
label="Manage Users"
current_path={@current_path || ""}
nested={true}
/>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/users/live_sessions")}
icon="live_sessions"
label="Live Sessions"
current_path={@current_path || ""}
nested={true}
/>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/users/sessions")}
icon="sessions"
label="Sessions"
current_path={@current_path || ""}
nested={true}
/>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/users/roles")}
icon="roles"
label="Roles"
current_path={@current_path || ""}
nested={true}
/>
<%= if PhoenixKit.ReferralCodes.enabled?() do %>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/users/referral-codes")}
icon="referral_codes"
label="Referral Codes"
current_path={@current_path || ""}
nested={true}
/>
<% end %>
</div>
<% end %>
<%= if PhoenixKit.Emails.enabled?() do %>
<%!-- Email section with direct link and conditional submenu --%>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/emails/dashboard")}
icon="email"
label="Emails"
current_path={@current_path || ""}
disable_active={true}
/>
<%= if submenu_open?(@current_path, ["/admin/emails", "/admin/emails/dashboard", "/admin/modules/emails/templates", "/admin/emails/queue", "/admin/emails/blocklist"]) do %>
<%!-- Email submenu items --%>
<div class="mt-1">
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/emails/dashboard")}
icon="email"
label="Dashboard"
current_path={@current_path || ""}
nested={true}
/>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/emails")}
icon="email"
label="Emails"
current_path={@current_path || ""}
nested={true}
/>
<.admin_nav_item
href={Routes.path("/admin/modules/emails/templates")}
icon="email"
label="Templates"
current_path={@current_path || ""}
nested={true}
/>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/emails/queue")}
icon="email"
label="Queue"
current_path={@current_path || ""}
nested={true}
/>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/emails/blocklist")}
icon="email"
label="Blocklist"
current_path={@current_path || ""}
nested={true}
/>
</div>
<% end %>
<% end %>
<%= if PhoenixKit.Entities.enabled?() do %>
<%!-- Entities section with direct link and conditional submenu --%>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/entities")}
icon="entities"
label="Entities"
current_path={@current_path || ""}
disable_active={true}
/>
<%= if submenu_open?(@current_path, ["/admin/entities"]) do %>
<%!-- Entities submenu items --%>
<div class="mt-1">
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/entities")}
icon="entities"
label="Entities"
current_path={@current_path || ""}
nested={true}
/>
<%!-- Dynamically list each published entity (one level deeper) --%>
<div class="pl-4">
<%= for entity <- PhoenixKit.Entities.list_entities() do %>
<%= if entity.status == "published" do %>
<.admin_nav_item
href={
Routes.locale_aware_path(assigns, "/admin/entities/#{entity.name}/data")
}
icon={entity.icon || "hero-cube"}
label={entity.display_name_plural || entity.display_name}
current_path={@current_path || ""}
nested={true}
/>
<% end %>
<% end %>
</div>
</div>
<% end %>
<% end %>
<%= if PhoenixKit.Pages.enabled?() do %>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/pages")}
icon="document"
label="Pages"
current_path={@current_path || ""}
/>
<% end %>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/modules")}
icon="modules"
label="Modules"
current_path={@current_path || ""}
/>
<%!-- Settings section with direct link and conditional submenu --%>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/settings")}
icon="settings"
label="Settings"
current_path={@current_path || ""}
disable_active={true}
/>
<%= if submenu_open?(@current_path, ["/admin/settings", "/admin/settings/users", "/admin/settings/referral-codes", "/admin/settings/emails", "/admin/settings/languages", "/admin/settings/entities"]) do %>
<%!-- Settings submenu items --%>
<div class="mt-1">
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/settings")}
icon="settings"
label="General"
current_path={@current_path || ""}
nested={true}
/>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/settings/users")}
icon="users"
label="Users"
current_path={@current_path || ""}
nested={true}
/>
<%= if PhoenixKit.ReferralCodes.enabled?() do %>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/settings/referral-codes")}
icon="referral_codes"
label="Referrals"
current_path={@current_path || ""}
nested={true}
/>
<% end %>
<%= if PhoenixKit.Emails.enabled?() do %>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/settings/emails")}
icon="email"
label="Emails"
current_path={@current_path || ""}
nested={true}
/>
<% end %>
<%= if Languages.enabled?() do %>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/settings/languages")}
icon="language"
label="Languages"
current_path={@current_path || ""}
nested={true}
/>
<% end %>
<%= if PhoenixKit.Modules.Maintenance.module_enabled?() do %>
<.admin_nav_item
href={Routes.locale_aware_path(assigns, "/admin/settings/maintenance")}
icon="maintenance"
label="Maintenance"
current_path={@current_path || ""}
nested={true}
/>
<% end %>
<%= if PhoenixKit.Entities.enabled?() do %>
<.admin_nav_item
href={Routes.path("/admin/settings/entities")}
icon="entities"
label="Entities"
current_path={@current_path || ""}
nested={true}
/>
<% end %>
</div>
<% end %>
</nav>
</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
document.addEventListener('phx:set-admin-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
# Check if a submenu should be open based on current path
defp submenu_open?(current_path, paths) when is_binary(current_path) do
# Remove PhoenixKit prefix first
normalized_path = String.replace_prefix(current_path, "/phoenix_kit", "")
# Remove locale prefix (e.g., /es, /fr, etc.) - keep leading slash
normalized_path =
case String.split(normalized_path, "/", parts: 3) do
["", locale, rest] when locale != "" and rest != "" ->
# Check if locale looks like a locale code (2-3 chars)
if String.length(locale) <= 3 do
"/" <> rest
else
normalized_path
end
_ ->
normalized_path
end
Enum.any?(paths, fn path -> String.starts_with?(normalized_path, path) end)
end
defp submenu_open?(_, _), do: false
# 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"""
<!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"} Admin"}>
{assigns[:page_title] || "Admin"}
</.live_title>
<link phx-track-static rel="stylesheet" href="/assets/css/app.css" />
</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>
</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())
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 application environment with Phoenix version compatibility
defp get_layout_config do
case Application.get_env(:phoenix_kit, :phoenix_version_strategy) do
:modern ->
# Phoenix v1.8+ - get layouts_module and assume :app function
case Application.get_env(:phoenix_kit, :layouts_module) do
nil -> nil
module -> {module, :app}
end
:legacy ->
# Phoenix v1.7- - use legacy layout config
Application.get_env(:phoenix_kit, :layout)
nil ->
# Fallback - check for legacy layout config first
Application.get_env(:phoenix_kit, :layout)
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
# 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"""
<div class="dropdown dropdown-end w-full" style="position: relative;">
<%!-- Current Language Button --%>
<div tabindex="0" role="button" class="btn btn-outline btn-sm w-full justify-start">
<span class="text-lg">{get_language_flag(@current_language["code"])}</span>
<span class="truncate flex-1 text-left">{@current_language["name"]}</span>
<span class="text-xs"></span>
</div>
<%!-- Language Options Dropdown --%>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-full p-2 shadow-lg border border-base-300"
style="position: absolute; bottom: 100%; margin-bottom: 4px;"
>
<%= for language <- @other_languages do %>
<li>
<a
href={generate_language_switch_url(@current_path, language["code"])}
class="flex items-center gap-3 px-3 py-2 hover:bg-base-200 rounded-lg"
>
<span class="text-lg">{get_language_flag(language["code"])}</span>
<span>{language["name"]}</span>
</a>
</li>
<% end %>
</ul>
</div>
"""
else
~H""
end
else
~H""
end
end
# Used in HEEX template - compiler cannot detect usage
def get_language_flag(code) do
case code do
"en" -> "🇺🇸"
"es" -> "🇪🇸"
"fr" -> "🇫🇷"
"de" -> "🇩🇪"
"pt" -> "🇵🇹"
"it" -> "🇮🇹"
"nl" -> "🇳🇱"
"ru" -> "🇷🇺"
"zh-CN" -> "🇨🇳"
"ja" -> "🇯🇵"
_ -> "🌐"
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