Packages

phoenix_kit

1.7.175
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 user_dashboard_nav.ex
Raw

lib/phoenix_kit_web/components/user_dashboard_nav.ex

defmodule PhoenixKitWeb.Components.UserDashboardNav do
@moduledoc """
User dashboard navigation components for the PhoenixKit user dashboard.
Provides navigation elements specifically for user dashboard pages.
"""
use PhoenixKitWeb, :html
alias PhoenixKit.Modules.Languages
alias PhoenixKit.Modules.Languages.DialectMapper
alias PhoenixKit.Settings
alias PhoenixKit.Users.Auth.Scope
alias PhoenixKit.Utils.Routes
alias PhoenixKitWeb.Components.Core.LanguageSwitcher
# Guest dropdown link catalog. Each entry is
# `{key, path, icon, label_fn, setting_gate_fn}`; `label_fn`/`gate_fn`
# are zero-arity so gettext + settings are evaluated at render time
# (per-request locale / live setting), not at compile time.
@guest_link_catalog [
{:login, "/users/log-in", "hero-arrow-right-on-rectangle"},
{:register, "/users/register", "hero-user-plus"},
{:reset, "/users/reset-password", "hero-key"},
{:magic_link, "/users/magic-link", "hero-sparkles"}
]
@doc """
Renders the user widget for dashboard navigation.
For authenticated visitors this is the avatar dropdown (email,
Admin/Dashboard/Settings, language switcher, log out). For anonymous
visitors the same dropdown *shape* is rendered with a generic
"not signed in" icon and guest-relevant links (log in, sign up, forgot
password, magic link) plus the same language switcher — so a single
widget covers both states and always offers a language switcher.
## Attributes
* `:scope` — current scope; `nil`/unauthenticated renders the guest dropdown.
* `:current_path` — used for active-link highlighting and locale-switch URLs.
* `:current_locale` — base code of the active locale (highlighted in the list).
* `:show_language_switcher` — include the in-menu language list (default `true`).
Set `false` when the host renders a standalone switcher elsewhere to avoid
a duplicate. Applies to both the signed-in and guest states.
* `:guest_links` — which guest links may appear, e.g.
`[:login, :register, :reset, :magic_link]` (default: all). Links are also
gated by the `allow_registration` / `magic_link_login_enabled` settings, so
this list can only narrow, never force-enable a disabled feature.
* `:authenticated_links` — which authenticated-menu entries may appear, e.g.
`[:admin, :dashboard, :settings, :logout]` (default: all). Same narrowing
rule as `:guest_links` — `:admin` still requires `Scope.admin?/1` to be
true, so listing it can't grant an entry a non-admin shouldn't see. Use
this to hide entries (e.g. `:dashboard`) a host app's own navigation
already covers.
"""
attr(:scope, :any, default: nil)
attr(:current_path, :string, default: "")
attr(:current_locale, :string, default: "en")
attr(:admin_edit_url, :string, default: nil)
attr(:admin_edit_label, :string, default: nil)
attr(:show_language_switcher, :boolean, default: true)
attr(:guest_links, :list, default: [:login, :register, :reset, :magic_link])
attr(:authenticated_links, :list, default: [:admin, :dashboard, :settings, :logout])
def user_dropdown(assigns) do
user = Scope.user(assigns.scope)
assigns =
assigns
|> assign(:user, user)
~H"""
<%= if @scope && PhoenixKit.Users.Auth.Scope.authenticated?(@scope) do %>
<div class="dropdown dropdown-end">
<div
tabindex="0"
role="button"
class="cursor-pointer hover:opacity-80 transition-opacity"
>
<PhoenixKitWeb.Components.Core.UserInfo.user_avatar
user={@user}
size="md"
class="!rounded-lg"
/>
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[60] w-64 p-2 shadow-xl border border-base-300 mt-3 max-h-[calc(100vh-5rem)] overflow-y-auto flex-nowrap"
>
<li class="menu-title px-4 py-2">
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-base-content truncate">
{PhoenixKit.Users.Auth.Scope.user_email(@scope)}
</span>
</div>
</li>
<div class="divider my-0"></div>
<%= if :admin in @authenticated_links && PhoenixKit.Users.Auth.Scope.admin?(@scope) do %>
<li>
<.link
navigate={PhoenixKit.Utils.Routes.path("/admin")}
class={"flex items-center gap-3" <> if(active_path?(assigns[:current_path], "/admin"), do: " bg-primary text-primary-content", else: "")}
>
<.icon name="hero-shield-check" class="w-4 h-4" />
<span>{gettext("Admin Panel")}</span>
</.link>
</li>
<%= if @admin_edit_url do %>
<li>
<a
href={@admin_edit_url}
class="flex items-center gap-3"
>
<.icon name="hero-pencil-square" class="w-4 h-4" />
<span>{@admin_edit_label || "Edit"}</span>
</a>
</li>
<% end %>
<% end %>
<li :if={:dashboard in @authenticated_links}>
<.link
navigate={PhoenixKit.Utils.Routes.path("/dashboard", locale: @current_locale)}
class={"flex items-center gap-3" <> if(active_path?(assigns[:current_path], "/dashboard"), do: " bg-primary text-primary-content", else: "")}
>
<.icon name="hero-home" class="w-4 h-4" />
<span>{gettext("Dashboard")}</span>
</.link>
</li>
<li :if={:settings in @authenticated_links}>
<.link
navigate={PhoenixKit.Utils.Routes.path("/dashboard/settings", locale: @current_locale)}
class={"flex items-center gap-3" <> if(active_path?(assigns[:current_path], "/dashboard/settings"), do: " bg-primary text-primary-content", else: "")}
>
<.icon name="hero-cog-6-tooth" class="w-4 h-4" />
<span>{gettext("Settings")}</span>
</.link>
</li>
<.language_menu_section
:if={@show_language_switcher}
current_path={@current_path}
current_locale={@current_locale}
/>
<div class="divider my-0"></div>
<li :if={:logout in @authenticated_links}>
<.link
navigate={Routes.path("/users/log-out")}
method="delete"
class="flex items-center gap-3 text-error hover:bg-error hover:text-error-content"
>
<.icon name="hero-arrow-right-on-rectangle" class="w-4 h-4" />
<span>{gettext("Log Out")}</span>
</.link>
</li>
</ul>
</div>
<% else %>
<.guest_dropdown
current_path={@current_path}
current_locale={@current_locale}
guest_links={@guest_links}
show_language_switcher={@show_language_switcher}
/>
<% end %>
"""
end
# Guest counterpart to the authenticated avatar dropdown: same shape and
# styling, a generic "not signed in" trigger icon, guest-relevant links,
# and the shared language switcher.
attr(:current_path, :string, required: true)
attr(:current_locale, :string, required: true)
attr(:guest_links, :list, required: true)
attr(:show_language_switcher, :boolean, required: true)
defp guest_dropdown(assigns) do
assigns = assign(assigns, :links, visible_guest_links(assigns.guest_links))
~H"""
<div class="dropdown dropdown-end">
<div
tabindex="0"
role="button"
class="cursor-pointer hover:opacity-80 transition-opacity"
aria-label={gettext("Account menu")}
>
<%!--
Rounded-rectangle placeholder matching the authenticated avatar shape
(md = w-10 h-10, rounded-lg via its `!rounded-lg`), so the guest and
signed-in triggers look consistent — a generic person silhouette
signals "not signed in".
--%>
<div class="w-10 h-10 rounded-lg bg-base-300 flex items-center justify-center text-base-content/60">
<.icon name="hero-user" class="w-6 h-6" />
</div>
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[60] w-64 p-2 shadow-xl border border-base-300 mt-3 max-h-[calc(100vh-5rem)] overflow-y-auto flex-nowrap"
>
<li class="menu-title px-4 py-2">
<span class="text-sm font-medium text-base-content">{gettext("Not signed in")}</span>
</li>
<div class="divider my-0"></div>
<li :for={link <- @links}>
<.link navigate={Routes.path(link.path)} class="flex items-center gap-3">
<.icon name={link.icon} class="w-4 h-4" />
<span>{link.label}</span>
</.link>
</li>
<.language_menu_section
:if={@show_language_switcher}
current_path={@current_path}
current_locale={@current_locale}
/>
</ul>
</div>
"""
end
# Shared, independently scrollable language list used by both the
# authenticated and guest dropdowns. Renders nothing when fewer than two
# languages are enabled. Anchors are styled directly (no nested daisyUI
# `<ul class="menu">`) so the parent menu doesn't apply submenu indent or
# li-child padding that would force horizontal scroll. See admin_nav.ex
# for the matching pattern.
attr(:current_path, :string, required: true)
attr(:current_locale, :string, required: true)
defp language_menu_section(assigns) do
# Highlight by BASE code, not full-dialect equality. `@current_locale`
# may be a base ("en", per the attr doc) or a resolved dialect that
# differs from the enabled one (e.g. `resolve_dialect("en")` => "en-US"
# while English is enabled as "en-GB"), so a raw `==` against the
# enabled dialect never matched on the default locale. Comparing bases
# makes en/en-GB/en-US all match and works whether the caller passes a
# base or a dialect — same rule the standalone `Core.LanguageSwitcher` uses.
current_base = DialectMapper.extract_base(assigns.current_locale)
user_languages =
Enum.map(get_user_languages(), fn language ->
Map.put(language, :active?, DialectMapper.extract_base(language.code) == current_base)
end)
assigns = assign(assigns, :user_languages, user_languages)
~H"""
<%= if length(@user_languages) > 1 do %>
<div class="divider my-0"></div>
<li class="menu-title px-4 py-1">
<span class="text-xs">{gettext("Language")}</span>
</li>
<li class="p-0 !bg-transparent hover:!bg-transparent focus:!bg-transparent">
<div class="!p-0 block w-full max-h-60 overflow-y-auto overflow-x-hidden !bg-transparent hover:!bg-transparent focus:!bg-transparent">
<a
:for={language <- @user_languages}
href={generate_language_switch_url(@current_path, language.code)}
class={[
"w-full flex items-center gap-3 rounded-lg px-4 py-2 text-sm",
"focus:outline-none focus-visible:outline-none",
if(language.active?,
do:
"bg-primary !text-primary-content hover:bg-primary hover:!text-primary-content active:!bg-primary/80",
else:
"!text-base-content hover:bg-base-200 hover:!text-base-content focus:!text-base-content active:!text-base-content active:!bg-base-300"
)
]}
>
<span class="text-lg">{get_language_flag(language.code)}</span>
<span>{language.name}</span>
<%= if language.active? do %>
<PhoenixKitWeb.Components.Core.Icons.icon_check class="w-4 h-4 ml-auto" />
<% end %>
</a>
</div>
</li>
<% end %>
"""
end
# Resolves which guest links to render: starts from the catalog, keeps
# only keys the caller allowed, and applies the per-feature settings
# gates (`allow_registration`, `magic_link_login_enabled`). Log in and
# forgot-password are always available; the others are gated.
defp visible_guest_links(allowed) do
allow_registration? = Settings.get_boolean_setting("allow_registration", true)
magic_link? = Settings.get_boolean_setting("magic_link_login_enabled", true)
@guest_link_catalog
|> Enum.filter(fn {key, _path, _icon} ->
key in allowed and guest_link_enabled?(key, allow_registration?, magic_link?)
end)
|> Enum.map(fn {key, path, icon} ->
%{key: key, path: path, icon: icon, label: guest_link_label(key)}
end)
end
defp guest_link_enabled?(:register, allow_registration?, _magic?), do: allow_registration?
defp guest_link_enabled?(:magic_link, _allow?, magic?), do: magic?
defp guest_link_enabled?(_key, _allow?, _magic?), do: true
defp guest_link_label(:login), do: gettext("Log in")
defp guest_link_label(:register), do: gettext("Sign up")
defp guest_link_label(:reset), do: gettext("Forgot password")
defp guest_link_label(:magic_link), do: gettext("Magic link")
# Helper function to get user languages from Languages module
# Returns enabled languages or falls back to English if module is disabled
defp get_user_languages do
# Get enabled languages from the Languages module
languages =
if Languages.enabled?() do
Languages.get_enabled_languages()
else
# Fallback to English when module is disabled
[%{code: "en-US", name: "English (United States)", is_enabled: true}]
end
# Map to expected format with enriched data from predefined languages.
# The final `dedupe_names/1` call drops the country qualifier when
# only one dialect of a given base language is configured — the
# same rule the frontend switcher applies, called from its
# canonical home in `Core.LanguageSwitcher` so the logic lives in
# one place.
languages
|> Enum.map(fn lang ->
case Languages.get_predefined_language(lang.code) do
%{name: name, flag: flag, native: native} ->
%{code: lang.code, name: name, flag: flag, native: native}
nil ->
%{code: lang.code, name: String.upcase(lang.code), flag: "🌐", native: ""}
end
end)
|> LanguageSwitcher.dedupe_names()
end
# Helper function to get language flag emoji
defp get_language_flag(code) when is_binary(code) do
case Languages.get_predefined_language(code) do
%{flag: flag} -> flag
nil -> "🌐"
end
end
# Check if current path matches the given path
defp active_path?(current_path, path) when is_binary(current_path) and is_binary(path) do
# Remove PhoenixKit prefix if present
normalized_path = remove_phoenix_kit_prefix(current_path)
# Remove locale prefix if present
clean_path = remove_locale_prefix(normalized_path)
# Check for exact match or ends with path
clean_path == path or String.ends_with?(clean_path, path)
end
defp active_path?(_, _), do: false
# Remove PhoenixKit prefix
defp remove_phoenix_kit_prefix(path) do
url_prefix = PhoenixKit.Config.get_url_prefix()
if url_prefix == "/" do
path
else
String.replace_prefix(path, url_prefix, "")
end
end
# Remove locale prefix
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
["", locale] ->
if looks_like_locale?(locale), do: "/", else: path
_ ->
path
end
end
# Check if it looks like a locale code
defp looks_like_locale?(locale) do
String.length(locale) <= 8 and String.match?(locale, ~r/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/)
end
# Legacy helper - kept for backward compatibility
defp generate_language_switch_url(current_path, new_locale) do
base_code = DialectMapper.extract_base(new_locale)
# Extract the path without locale and regenerate with new locale
url_prefix = PhoenixKit.Config.get_url_prefix()
base_prefix = if url_prefix === "/", do: "", else: url_prefix
# Remove prefix and locale from current_path
clean_path =
current_path
|> String.replace_prefix(base_prefix, "")
|> remove_locale_from_path()
# Generate new path with the new locale
Routes.path(clean_path, locale: base_code)
end
# Remove locale from path.
#
# A path that is *only* a locale segment (e.g. "/ru", "/en-GB") reduces
# to "/". The empty-`rest` guard matters: `Path.join([])` raises a
# FunctionClauseError, which used to 500 a host's bare `/:locale` landing
# route (e.g. a locale-prefixed landing page introduced in 1.7.150+).
defp remove_locale_from_path(path) do
case String.split(path, "/", trim: true) do
[segment | rest] when byte_size(segment) in [2, 5] ->
cond do
not locale_segment?(segment) -> path
rest == [] -> "/"
true -> "/" <> Path.join(rest)
end
_ ->
path
end
end
# A 2-char base code or a 5-char dialect ("en-GB"). Mirrors the original
# inline guard; kept narrow on purpose so a real 3-char page segment
# (e.g. "/faq") isn't mistaken for a locale.
defp locale_segment?(segment) do
String.length(segment) == 2 or
(String.length(segment) == 5 and String.contains?(segment, "-"))
end
end