diff --git a/CLAUDE.md b/CLAUDE.md
index b380dd3d..d0678311 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -43,6 +43,7 @@ This is **PhoenixKit** - PhoenixKit is a starter kit for building modern web app
### Quick Reference
- **Tabs & Subtabs** - Configurable navigation with parent/child relationships, custom styling, animations
+- **Admin Navigation** - Registry-driven admin sidebar with permission gating, dynamic children, and parent app customization via `:admin_dashboard_tabs` config (see `ADMIN_README.md`)
- **Context Selectors** - Organization/team/project switching with dependencies
- **Theme Switcher** - `dashboard_themes` config, auto-renders in header
- **Live Badges** - Real-time badge updates via PubSub
@@ -662,6 +663,29 @@ See `lib/phoenix_kit/dashboard/README.md` for full documentation. Quick access i
- Single context: `socket.assigns.current_context`
- Multi context: `socket.assigns.current_contexts_map[:key]`
+### Admin Dashboard Customization
+
+The admin sidebar uses a registry-driven system with permission gating. See `lib/phoenix_kit/dashboard/ADMIN_README.md` for full documentation.
+
+**Add custom admin tabs from parent app:**
+
+```elixir
+config :phoenix_kit, :admin_dashboard_tabs, [
+ %{
+ id: :admin_analytics,
+ label: "Analytics",
+ icon: "hero-chart-bar",
+ path: "/admin/analytics",
+ permission: "dashboard",
+ priority: 150,
+ group: :admin_main,
+ live_view: {MyAppWeb.PhoenixKitLive.AdminAnalyticsLive, :index}
+ }
+]
+```
+
+The `live_view` field auto-generates a route inside PhoenixKit's admin `live_session` for seamless navigation. Custom admin LiveViews must use `LayoutWrapper.app_layout` (not `Layouts.dashboard`) and `@url_path` for the current path. See the ADMIN_README for the full LiveView template pattern.
+
### Dashboard Layout Performance (IMPORTANT)
**Do not pass all assigns to the dashboard layout.** Use `dashboard_assigns/1`:
diff --git a/guides/integration.md b/guides/integration.md
index b0088b02..6313dd7a 100644
--- a/guides/integration.md
+++ b/guides/integration.md
@@ -471,6 +471,69 @@ Scope.accessible_modules(scope) # MapSet of granted permission keys
---
+## Custom Admin Pages
+
+Add custom pages to the PhoenixKit admin sidebar with seamless LiveView navigation.
+
+### Step 1: Create the LiveView
+
+```elixir
+# lib/my_app_web/phoenix_kit_live/admin_analytics_live.ex
+defmodule MyAppWeb.PhoenixKitLive.AdminAnalyticsLive do
+ use MyAppWeb, :live_view
+
+ def mount(_params, _session, socket) do
+ {:ok, assign(socket, page_title: "Analytics")}
+ end
+
+ def render(assigns) do
+ ~H"""
+
+
+
Analytics
+
+
+ """
+ end
+end
+```
+
+### Step 2: Register the Tab
+
+```elixir
+# config/config.exs
+config :phoenix_kit, :admin_dashboard_tabs, [
+ %{
+ id: :admin_analytics,
+ label: "Analytics",
+ icon: "hero-chart-bar",
+ path: "/admin/analytics",
+ permission: "dashboard",
+ priority: 150,
+ group: :admin_main,
+ live_view: {MyAppWeb.PhoenixKitLive.AdminAnalyticsLive, :index}
+ }
+]
+```
+
+The `live_view` field tells PhoenixKit to auto-generate a route inside the shared admin `live_session`, so navigation from other admin pages is seamless (no full page reload).
+
+**Key rules:**
+- Use `@url_path` for `current_path` (set by PhoenixKit's on_mount hooks)
+- Use `LayoutWrapper.app_layout` for the admin layout (NOT `Layouts.dashboard`)
+- Don't pass `project_title` — the layout has a built-in default
+- Use `assigns[:current_locale]` (bracket access for optional assigns)
+
+See the [Admin Navigation docs](../lib/phoenix_kit/dashboard/ADMIN_README.md) for the full reference.
+
+---
+
## Common Tasks
### Task: Add PhoenixKit Navigation to Your Layout
@@ -881,4 +944,4 @@ records = PhoenixKit.Entities.EntityData.list_by_entity(entity.id)
---
-**Last Updated**: 2026-02-08
+**Last Updated**: 2026-02-14
diff --git a/lib/modules/publishing/publishing.ex b/lib/modules/publishing/publishing.ex
index 437b5bd7..d73c4a0a 100644
--- a/lib/modules/publishing/publishing.ex
+++ b/lib/modules/publishing/publishing.ex
@@ -344,21 +344,10 @@ defmodule PhoenixKit.Modules.Publishing do
"""
@spec enabled?() :: boolean()
def enabled? do
- # Check new key first using get_setting (allows nil default)
- case settings_call(:get_setting, [@publishing_enabled_key, nil]) do
- nil ->
- # Fall back to legacy key
- settings_call(:get_boolean_setting, [@legacy_enabled_key, false])
-
- "true" ->
- true
-
- true ->
- true
-
- _ ->
- false
- end
+ # Check new key first, then fall back to legacy key
+ # Uses get_boolean_setting (cached) to avoid DB queries on every sidebar render
+ settings_call(:get_boolean_setting, [@publishing_enabled_key, false]) or
+ settings_call(:get_boolean_setting, [@legacy_enabled_key, false])
end
@doc """
diff --git a/lib/phoenix_kit/config/config.ex b/lib/phoenix_kit/config/config.ex
index a6f13279..1dc45be6 100644
--- a/lib/phoenix_kit/config/config.ex
+++ b/lib/phoenix_kit/config/config.ex
@@ -170,7 +170,7 @@ defmodule PhoenixKit.Config do
dashboard_context_selectors: nil,
# Subtab styling defaults
dashboard_subtab_style: [
- indent: "pl-9",
+ indent: "pl-4",
icon_size: "w-4 h-4",
text_size: "text-sm",
animation: :none
diff --git a/lib/phoenix_kit/dashboard/ADMIN_README.md b/lib/phoenix_kit/dashboard/ADMIN_README.md
new file mode 100644
index 00000000..873f52a3
--- /dev/null
+++ b/lib/phoenix_kit/dashboard/ADMIN_README.md
@@ -0,0 +1,401 @@
+# PhoenixKit Admin Navigation System
+
+Registry-driven admin sidebar navigation that replaces hardcoded HEEX with configurable, permission-gated Tab structs. Shares the same underlying registry and rendering infrastructure as the [User Dashboard Tab System](README.md).
+
+## How It Works
+
+All admin navigation items are registered as Tab structs in the Dashboard Registry with `level: :admin`. The admin sidebar component reads these tabs, filters by permission and module-enabled status, and renders them using the same `TabItem` component as the user dashboard.
+
+### Three-Layer Visibility
+
+Every admin tab passes through three filters before rendering:
+
+1. **Module Enabled** — Is the feature module active? (e.g., is Billing enabled?)
+2. **Permission Granted** — Does the user's role have access? (checked via `Scope.has_module_access?/2`)
+3. **Custom Visibility** — Optional `visible` function for special logic
+
+```
+Tab registered → module_enabled? → permission_granted? → visible? → rendered
+```
+
+## Default Admin Tabs
+
+PhoenixKit registers ~50 admin tabs automatically on startup, organized into three groups:
+
+| Group | Tabs |
+|-------|------|
+| **Main** | Dashboard, Users (+ 6 subtabs), Media |
+| **Modules** | Emails, Billing, Shop, Entities, AI, Sync, DB, Posts, Comments, Publishing, Jobs, Tickets, Modules |
+| **System** | Settings (+ ~20 subtabs covering all module settings) |
+
+Each tab has a `permission` field matching one of the 25 permission keys (e.g., `"billing"`, `"users"`, `"settings"`). Tabs for disabled modules are automatically hidden.
+
+## Customizing Admin Tabs
+
+### Adding Tabs via Config
+
+Add custom tabs to the admin sidebar:
+
+```elixir
+# config/config.exs
+config :phoenix_kit, :admin_dashboard_tabs, [
+ %{
+ id: :admin_analytics,
+ label: "Analytics",
+ icon: "hero-chart-bar",
+ path: "/admin/analytics",
+ permission: "dashboard",
+ priority: 350,
+ group: :admin_main
+ }
+]
+```
+
+### Adding Tabs with Seamless Navigation
+
+By default, custom tabs are sidebar links only — the parent app must define the actual LiveView routes. If those routes are in a different `live_session`, navigation causes a full page reload.
+
+To avoid this, add the `live_view` field. PhoenixKit will auto-generate the route inside its shared admin `live_session`, giving you seamless LiveView navigation:
+
+```elixir
+config :phoenix_kit, :admin_dashboard_tabs, [
+ %{
+ id: :admin_analytics,
+ label: "Analytics",
+ icon: "hero-chart-bar",
+ path: "/admin/analytics",
+ permission: "dashboard",
+ priority: 350,
+ group: :admin_main,
+ live_view: {MyAppWeb.AnalyticsLive, :index} # Auto-generates route
+ }
+]
+```
+
+With `live_view` set, PhoenixKit:
+- Generates `live "/admin/analytics", MyAppWeb.AnalyticsLive, :index` inside the admin `live_session`
+- Applies the `:phoenix_kit_ensure_admin` on_mount hook automatically
+- Navigation from other admin pages uses LiveView `navigate` (no full page reload)
+
+**Without `live_view`**: Parent app defines routes in its own router (may be a different `live_session`).
+
+### Tab Fields Reference
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `id` | atom | required | Unique identifier (prefix with `admin_` by convention) |
+| `label` | string | required | Display text in sidebar |
+| `icon` | string | nil | Heroicon name (e.g., `"hero-chart-bar"`) |
+| `path` | string | required | URL path without prefix (e.g., `"/admin/analytics"`) |
+| `priority` | integer | 500 | Sort order (lower = higher in sidebar) |
+| `level` | atom | `:admin` | Set automatically by config loader |
+| `permission` | string | nil | Permission key for access control (e.g., `"billing"`) |
+| `group` | atom | nil | Group ID: `:admin_main`, `:admin_modules`, or `:admin_system` |
+| `parent` | atom | nil | Parent tab ID for subtab relationships |
+| `match` | atom | `:prefix` | Path matching: `:exact`, `:prefix`, or `{:regex, ~r/...}` |
+| `visible` | function | nil | `(scope -> boolean)` for custom visibility logic |
+| `live_view` | tuple | nil | `{Module, :action}` to auto-generate a route |
+| `subtab_display` | atom | `:when_active` | `:when_active` or `:always` |
+| `highlight_with_subtabs` | boolean | false | Highlight parent when subtab is active |
+| `dynamic_children` | function | nil | `(scope -> [Tab.t()])` for runtime subtabs |
+
+### Modifying Default Tabs
+
+Update or remove default tabs at runtime:
+
+```elixir
+# Change a default tab's label or icon
+PhoenixKit.Dashboard.update_tab(:admin_dashboard, %{label: "Home", icon: "hero-home"})
+
+# Remove a default tab
+PhoenixKit.Dashboard.unregister_tab(:admin_jobs)
+```
+
+### Registering Tabs at Runtime
+
+```elixir
+# Register admin tabs programmatically (level: :admin is set automatically)
+PhoenixKit.Dashboard.register_admin_tabs(:my_app, [
+ %{
+ id: :admin_analytics,
+ label: "Analytics",
+ icon: "hero-chart-bar",
+ path: "/admin/analytics",
+ permission: "dashboard",
+ priority: 350,
+ group: :admin_main
+ }
+])
+
+# Unregister all tabs for a namespace
+PhoenixKit.Dashboard.unregister_tabs(:my_app)
+```
+
+## Subtabs
+
+Admin tabs support parent/child relationships, working the same as [user dashboard subtabs](README.md#subtabs):
+
+```elixir
+config :phoenix_kit, :admin_dashboard_tabs, [
+ # Parent
+ %{
+ id: :admin_reports,
+ label: "Reports",
+ icon: "hero-document-chart-bar",
+ path: "/admin/reports",
+ permission: "dashboard",
+ priority: 360,
+ group: :admin_main,
+ subtab_display: :when_active,
+ live_view: {MyAppWeb.ReportsLive, :index}
+ },
+ # Subtabs
+ %{
+ id: :admin_reports_sales,
+ label: "Sales",
+ path: "/admin/reports/sales",
+ parent: :admin_reports,
+ priority: 361,
+ live_view: {MyAppWeb.ReportsSalesLive, :index}
+ },
+ %{
+ id: :admin_reports_users,
+ label: "Users",
+ path: "/admin/reports/users",
+ parent: :admin_reports,
+ priority: 362,
+ live_view: {MyAppWeb.ReportsUsersLive, :index}
+ }
+]
+```
+
+## Dynamic Children
+
+Some admin tabs generate subtabs at render time based on data:
+
+- **Entities** — A subtab for each published entity type
+- **Publishing** — A subtab for each publishing group from settings
+
+These use the `dynamic_children` field — a function `(scope -> [Tab.t()])` called when the sidebar renders. Dynamic children are always rendered under their parent tab and inherit its permission.
+
+### Custom Dynamic Children
+
+```elixir
+PhoenixKit.Dashboard.register_admin_tabs(:my_app, [
+ %{
+ id: :admin_workspaces,
+ label: "Workspaces",
+ icon: "hero-squares-2x2",
+ path: "/admin/workspaces",
+ permission: "dashboard",
+ priority: 400,
+ group: :admin_main,
+ dynamic_children: fn _scope ->
+ MyApp.Workspaces.list_active()
+ |> Enum.with_index()
+ |> Enum.map(fn {ws, idx} ->
+ %PhoenixKit.Dashboard.Tab{
+ id: :"admin_workspace_#{ws.slug}",
+ label: ws.name,
+ icon: "hero-square-2-stack",
+ path: "/admin/workspaces/#{ws.slug}",
+ priority: 401 + idx,
+ level: :admin,
+ permission: "dashboard",
+ match: :prefix,
+ parent: :admin_workspaces
+ }
+ end)
+ end
+ }
+])
+```
+
+**Performance note**: Dynamic children functions run on every sidebar render (each navigation). Keep them fast — use cached data, avoid expensive queries.
+
+## Permission System
+
+Admin tabs integrate with PhoenixKit's [module-level permissions](../../CLAUDE.md#module-level-permissions-v53):
+
+- **Owner** — Always has full access (hardcoded, no DB rows needed)
+- **Admin** — Gets all 25 permissions by default
+- **Custom roles** — Start with no permissions; grant via matrix UI or API
+
+### Permission Keys
+
+The `permission` field on a tab must match one of the 25 permission keys:
+
+**Core (always enabled):** `dashboard`, `users`, `media`, `settings`, `modules`
+
+**Feature modules (enabled/disabled):** `billing`, `shop`, `emails`, `entities`, `tickets`, `posts`, `comments`, `ai`, `sync`, `publishing`, `referrals`, `sitemap`, `seo`, `maintenance`, `storage`, `languages`, `connections`, `legal`, `db`, `jobs`
+
+When a tab's `permission` points to a feature module:
+- If the module is **disabled**, the tab is hidden for everyone
+- If the module is **enabled**, the tab is shown only to users whose role has that permission
+
+## Navigation Architecture
+
+### LiveView Sessions
+
+All PhoenixKit admin routes share a single `live_session`:
+
+```elixir
+live_session :phoenix_kit_admin,
+ on_mount: [{PhoenixKitWeb.Users.Auth, :phoenix_kit_ensure_admin}] do
+ # All admin routes — PhoenixKit core + modules + custom (with live_view)
+end
+```
+
+This means:
+- Navigating between admin pages uses LiveView `navigate` (WebSocket stays alive)
+- Each page does a lightweight MOUNT (expected behavior for different LiveView modules)
+- No full page reloads within the admin panel
+
+**Important**: Custom admin routes defined by the parent app WITHOUT `live_view` may be in a different `live_session`, which would cause a full page reload when navigating to them. Use `live_view` in your tab config to avoid this.
+
+### Tab Rendering Flow
+
+```
+1. Registry.get_admin_tabs(scope: scope)
+ ├── Filter by level (:admin + :all)
+ ├── Filter by module enabled (deduplicated per permission key)
+ ├── Filter by permission (in-memory MapSet check)
+ └── Filter by visibility (custom functions)
+
+2. AdminSidebar component
+ ├── Expand dynamic children (entities, publishing)
+ ├── Add active state based on current_path
+ ├── Group tabs by group field
+ └── Render via TabItem component (shared with user dashboard)
+```
+
+**Important**: Dynamic children are expanded *before* active state is applied, so that dynamically-generated subtabs (e.g., individual entity types) correctly highlight when navigated to.
+
+## API Reference
+
+```elixir
+# Admin-specific
+PhoenixKit.Dashboard.get_admin_tabs(opts) # Get filtered admin tabs
+PhoenixKit.Dashboard.get_user_tabs(opts) # Get filtered user tabs
+PhoenixKit.Dashboard.register_admin_tabs(ns, tabs) # Register with level: :admin
+PhoenixKit.Dashboard.update_tab(tab_id, attrs) # Modify existing tab
+PhoenixKit.Dashboard.load_admin_defaults() # Reload default admin tabs
+
+# All standard Dashboard APIs also work (see README.md)
+PhoenixKit.Dashboard.unregister_tab(tab_id)
+PhoenixKit.Dashboard.get_tab(tab_id)
+# etc.
+```
+
+## File Structure
+
+```
+lib/phoenix_kit/dashboard/
+├── admin_tabs.ex # Default admin tab definitions (~50 tabs)
+├── dashboard.ex # Public API facade
+├── registry.ex # Tab registry GenServer (shared user + admin)
+├── tab.ex # Tab struct with level/permission/dynamic_children fields
+├── ADMIN_README.md # This file
+└── README.md # User dashboard documentation
+
+lib/phoenix_kit_web/components/dashboard/
+├── admin_sidebar.ex # Admin sidebar component
+├── sidebar.ex # User dashboard sidebar component
+├── tab_item.ex # Shared tab rendering component
+└── ...
+```
+
+## Creating Custom Admin Pages
+
+When using the `live_view` field, your LiveView runs inside PhoenixKit's admin `live_session` and must use the admin layout. Here's the complete pattern:
+
+### 1. Create the LiveView
+
+```elixir
+# lib/my_app_web/phoenix_kit_live/admin_analytics_live.ex
+defmodule MyAppWeb.PhoenixKitLive.AdminAnalyticsLive do
+ use MyAppWeb, :live_view
+
+ def mount(_params, _session, socket) do
+ {:ok, assign(socket, page_title: "Analytics")}
+ end
+
+ def render(assigns) do
+ ~H"""
+
+
+
Analytics Dashboard
+ <%!-- Your content here --%>
+
+
+ """
+ end
+end
+```
+
+### 2. Register the Tab
+
+```elixir
+# config/config.exs
+config :phoenix_kit, :admin_dashboard_tabs, [
+ %{
+ id: :admin_analytics,
+ label: "Analytics",
+ icon: "hero-chart-bar",
+ path: "/admin/analytics",
+ permission: "dashboard",
+ priority: 150,
+ group: :admin_main,
+ live_view: {MyAppWeb.PhoenixKitLive.AdminAnalyticsLive, :index}
+ }
+]
+```
+
+### Key Points
+
+- **Use `@url_path` not `@current_path`** — The `url_path` assign is set by PhoenixKit's `on_mount` hooks. There is no `current_path` assign.
+- **Use `LayoutWrapper.app_layout`** — This is the admin layout with the admin sidebar. Do NOT use `Layouts.dashboard` (that's the user dashboard layout).
+- **Don't pass `project_title`** — The `app_layout` component has a built-in default; passing it from the LiveView will crash since it's not in the assigns.
+- **Use `assigns[:current_locale]`** — Use bracket access for optional assigns that may not be set.
+- **Place LiveViews under `phoenix_kit_live/`** — Convention for LiveViews that run inside PhoenixKit's admin `live_session`.
+
+### Available Assigns
+
+These assigns are automatically set by PhoenixKit's `on_mount` hooks in the admin `live_session`:
+
+| Assign | Type | Description |
+|--------|------|-------------|
+| `@url_path` | string | Current URL path (use for `current_path` in layout) |
+| `@phoenix_kit_current_scope` | Scope.t() | Auth scope with user, roles, and permissions |
+| `@phoenix_kit_current_user` | User.t() | Current authenticated user |
+| `@current_locale` | string | Current locale code (may be nil) |
+| `@flash` | map | Flash messages |
+| `@live_action` | atom | The action from the route (e.g., `:index`) |
+| `@show_maintenance` | boolean | Whether maintenance mode banner is shown |
+
+## Legacy Config Compatibility
+
+The legacy `AdminDashboardCategories` config format is still supported but deprecated:
+
+```elixir
+# Legacy format (deprecated, will log warning)
+config :phoenix_kit, AdminDashboardCategories, [
+ %{title: "Custom", icon: "hero-star", tabs: [
+ %{title: "Analytics", url: "/admin/analytics", icon: "hero-chart-bar"}
+ ]}
+]
+
+# New format (recommended)
+config :phoenix_kit, :admin_dashboard_tabs, [
+ %{id: :admin_analytics, label: "Analytics", icon: "hero-chart-bar",
+ path: "/admin/analytics", permission: "dashboard", group: :admin_main}
+]
+```
+
+Legacy categories are automatically converted to admin Tab structs at startup. A deprecation warning is logged when legacy config is detected.
diff --git a/lib/phoenix_kit/dashboard/README.md b/lib/phoenix_kit/dashboard/README.md
index 9702b8cd..e8416278 100644
--- a/lib/phoenix_kit/dashboard/README.md
+++ b/lib/phoenix_kit/dashboard/README.md
@@ -258,7 +258,7 @@ config :phoenix_kit, :user_dashboard_tabs, [
]
```
-> **Note:** Currently only one level of nesting is supported (parent → subtab). Sub-sub-tabs (nested subtabs) are not yet implemented but may be added in a future release.
+> **Note:** The user dashboard supports one level of nesting (parent → subtab). The admin sidebar supports two levels of nesting (parent → subtab → sub-subtab) for cases like Settings > Media > Dimensions.
### Subtab Display Options
@@ -326,7 +326,7 @@ Set these on the **parent tab** to apply to all its subtabs, or on **individual
path: "/dashboard/orders",
subtab_display: :when_active,
# Style options for subtabs (applied to children)
- subtab_indent: "pl-12", # Tailwind padding-left class (default: "pl-9")
+ subtab_indent: "pl-12", # Tailwind padding-left class (default: "pl-4")
subtab_icon_size: "w-3 h-3", # Icon size classes (default: "w-4 h-4")
subtab_text_size: "text-xs", # Text size class (default: "text-sm")
subtab_animation: :slide # Animation: :none, :slide, :fade, :collapse
@@ -359,7 +359,7 @@ Set global subtab styling defaults in your config:
```elixir
config :phoenix_kit,
dashboard_subtab_style: [
- indent: "pl-9", # Default indent (Tailwind class)
+ indent: "pl-4", # Default indent (Tailwind class)
icon_size: "w-4 h-4", # Default icon size
text_size: "text-sm", # Default text size
animation: :none # Default animation
@@ -372,7 +372,7 @@ The `indent` option (for `subtab_indent` or `dashboard_subtab_style.indent`) sup
**Tailwind Classes (standard):**
```elixir
-indent: "pl-9" # Default: ~36px / 2.25rem
+indent: "pl-4" # Default: ~16px / 1rem
indent: "pl-12" # 3rem
indent: "pl-6" # 1.5rem
```
@@ -1030,19 +1030,26 @@ PhoenixKit.Dashboard.visible?(tab, scope)
```
lib/phoenix_kit/dashboard/
├── dashboard.ex # Main public API
-├── tab.ex # Tab struct and logic
+├── tab.ex # Tab struct and logic (level, permission, dynamic_children)
├── badge.ex # Badge struct and logic
-├── registry.ex # Tab registry GenServer
+├── registry.ex # Tab registry GenServer (shared user + admin)
+├── admin_tabs.ex # Default admin tab definitions (~50 tabs)
├── presence.ex # Presence tracking
-└── README.md # This file
+├── README.md # This file (user dashboard)
+└── ADMIN_README.md # Admin navigation documentation
lib/phoenix_kit_web/components/dashboard/
-├── sidebar.ex # Main sidebar component
-├── tab_item.ex # Individual tab component
+├── sidebar.ex # User dashboard sidebar component
+├── admin_sidebar.ex # Admin sidebar component
+├── tab_item.ex # Shared tab rendering component
├── badge.ex # Badge rendering component
└── live_tabs.ex # LiveView integration helpers
```
+## Admin Navigation
+
+The admin sidebar uses the same registry-driven system with additional features for permissions, module-enabled filtering, and dynamic children. See [ADMIN_README.md](ADMIN_README.md) for full documentation.
+
## Default Tabs
PhoenixKit provides these default tabs:
diff --git a/lib/phoenix_kit/dashboard/admin_tabs.ex b/lib/phoenix_kit/dashboard/admin_tabs.ex
new file mode 100644
index 00000000..5a35a11c
--- /dev/null
+++ b/lib/phoenix_kit/dashboard/admin_tabs.ex
@@ -0,0 +1,1024 @@
+defmodule PhoenixKit.Dashboard.AdminTabs do
+ @moduledoc """
+ Default admin navigation tabs for PhoenixKit.
+
+ Defines all admin sidebar navigation items as Tab structs.
+ These are registered in the Dashboard Registry during initialization
+ and can be customized by parent applications via config.
+ """
+
+ alias PhoenixKit.Dashboard.Tab
+ alias PhoenixKit.Modules.Entities
+ alias PhoenixKit.Settings
+ alias PhoenixKit.Users.Auth.Scope
+
+ # Suppress warnings about optional modules (loaded conditionally)
+ @compile {:no_warn_undefined, PhoenixKit.Modules.Tickets}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.Billing}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.Shop}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.Emails}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.Entities}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.AI}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.Sync}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.DB}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.Posts}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.Comments}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.Publishing}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.Referrals}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.Sitemap}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.SEO}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.Maintenance}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.Languages}
+ @compile {:no_warn_undefined, PhoenixKit.Modules.Legal}
+ @compile {:no_warn_undefined, PhoenixKit.Jobs}
+
+ @doc """
+ Returns all default admin tabs.
+ """
+ @spec default_tabs() :: [Tab.t()]
+ def default_tabs do
+ core_tabs() ++ module_tabs() ++ settings_tabs()
+ end
+
+ @doc """
+ Returns the default admin tab groups.
+ """
+ @spec default_groups() :: [map()]
+ def default_groups do
+ [
+ %{id: :admin_main, label: nil, priority: 100},
+ %{id: :admin_modules, label: nil, priority: 500},
+ %{id: :admin_system, label: nil, priority: 900}
+ ]
+ end
+
+ @doc """
+ Returns core admin tabs (always present, gated only by permission).
+ """
+ @spec core_tabs() :: [Tab.t()]
+ def core_tabs do
+ [
+ # Dashboard
+ %Tab{
+ id: :admin_dashboard,
+ label: "Dashboard",
+ icon: "hero-home",
+ path: "/admin",
+ priority: 100,
+ level: :admin,
+ permission: "dashboard",
+ match: :exact,
+ group: :admin_main
+ },
+ # Users parent
+ %Tab{
+ id: :admin_users,
+ label: "Users",
+ icon: "hero-users",
+ path: "/admin/users",
+ priority: 200,
+ level: :admin,
+ permission: "users",
+ match: :prefix,
+ group: :admin_main,
+ subtab_display: :when_active,
+ highlight_with_subtabs: false
+ },
+ # Users subtabs
+ %Tab{
+ id: :admin_users_manage,
+ label: "Manage Users",
+ icon: "hero-users",
+ path: "/admin/users",
+ priority: 210,
+ level: :admin,
+ permission: "users",
+ match: :exact,
+ parent: :admin_users
+ },
+ %Tab{
+ id: :admin_users_live_sessions,
+ label: "Live Sessions",
+ icon: "hero-eye",
+ path: "/admin/users/live_sessions",
+ priority: 220,
+ level: :admin,
+ permission: "users",
+ match: :prefix,
+ parent: :admin_users
+ },
+ %Tab{
+ id: :admin_users_sessions,
+ label: "Sessions",
+ icon: "hero-computer-desktop",
+ path: "/admin/users/sessions",
+ priority: 230,
+ level: :admin,
+ permission: "users",
+ match: :prefix,
+ parent: :admin_users
+ },
+ %Tab{
+ id: :admin_users_roles,
+ label: "Roles",
+ icon: "hero-shield-check",
+ path: "/admin/users/roles",
+ priority: 240,
+ level: :admin,
+ permission: "users",
+ match: :prefix,
+ parent: :admin_users
+ },
+ %Tab{
+ id: :admin_users_permissions,
+ label: "Permissions",
+ icon: "hero-key",
+ path: "/admin/users/permissions",
+ priority: 250,
+ level: :admin,
+ permission: "users",
+ match: :prefix,
+ parent: :admin_users
+ },
+ %Tab{
+ id: :admin_users_referral_codes,
+ label: "Referral Codes",
+ icon: "hero-ticket",
+ path: "/admin/users/referral-codes",
+ priority: 260,
+ level: :admin,
+ permission: "referrals",
+ match: :prefix,
+ parent: :admin_users
+ },
+ # Media
+ %Tab{
+ id: :admin_media,
+ label: "Media",
+ icon: "hero-photo",
+ path: "/admin/media",
+ priority: 300,
+ level: :admin,
+ permission: "media",
+ match: :prefix,
+ group: :admin_main
+ }
+ ]
+ end
+
+ @doc """
+ Returns feature module admin tabs (gated by module enabled + permission).
+ """
+ @spec module_tabs() :: [Tab.t()]
+ def module_tabs do
+ [
+ # Emails parent
+ %Tab{
+ id: :admin_emails,
+ label: "Emails",
+ icon: "hero-envelope",
+ path: "/admin/emails/dashboard",
+ priority: 510,
+ level: :admin,
+ permission: "emails",
+ match: :prefix,
+ group: :admin_modules,
+ subtab_display: :when_active,
+ highlight_with_subtabs: false,
+ subtab_indent: "pl-4"
+ },
+ %Tab{
+ id: :admin_emails_dashboard,
+ label: "Dashboard",
+ icon: "hero-envelope",
+ path: "/admin/emails/dashboard",
+ priority: 511,
+ level: :admin,
+ permission: "emails",
+ match: :prefix,
+ parent: :admin_emails
+ },
+ %Tab{
+ id: :admin_emails_list,
+ label: "Emails",
+ icon: "hero-envelope",
+ path: "/admin/emails",
+ priority: 512,
+ level: :admin,
+ permission: "emails",
+ match: :exact,
+ parent: :admin_emails
+ },
+ %Tab{
+ id: :admin_emails_templates,
+ label: "Templates",
+ icon: "hero-envelope",
+ path: "/admin/modules/emails/templates",
+ priority: 513,
+ level: :admin,
+ permission: "emails",
+ match: :prefix,
+ parent: :admin_emails
+ },
+ %Tab{
+ id: :admin_emails_queue,
+ label: "Queue",
+ icon: "hero-envelope",
+ path: "/admin/emails/queue",
+ priority: 514,
+ level: :admin,
+ permission: "emails",
+ match: :prefix,
+ parent: :admin_emails
+ },
+ %Tab{
+ id: :admin_emails_blocklist,
+ label: "Blocklist",
+ icon: "hero-envelope",
+ path: "/admin/emails/blocklist",
+ priority: 515,
+ level: :admin,
+ permission: "emails",
+ match: :prefix,
+ parent: :admin_emails
+ },
+ # Billing parent
+ %Tab{
+ id: :admin_billing,
+ label: "Billing",
+ icon: "hero-banknotes",
+ path: "/admin/billing",
+ priority: 520,
+ level: :admin,
+ permission: "billing",
+ match: :prefix,
+ group: :admin_modules,
+ subtab_display: :when_active,
+ highlight_with_subtabs: false
+ },
+ %Tab{
+ id: :admin_billing_dashboard,
+ label: "Dashboard",
+ icon: "hero-banknotes",
+ path: "/admin/billing",
+ priority: 521,
+ level: :admin,
+ permission: "billing",
+ match: :exact,
+ parent: :admin_billing
+ },
+ %Tab{
+ id: :admin_billing_orders,
+ label: "Orders",
+ icon: "hero-banknotes",
+ path: "/admin/billing/orders",
+ priority: 522,
+ level: :admin,
+ permission: "billing",
+ match: :prefix,
+ parent: :admin_billing
+ },
+ %Tab{
+ id: :admin_billing_invoices,
+ label: "Invoices",
+ icon: "hero-banknotes",
+ path: "/admin/billing/invoices",
+ priority: 523,
+ level: :admin,
+ permission: "billing",
+ match: :prefix,
+ parent: :admin_billing
+ },
+ %Tab{
+ id: :admin_billing_transactions,
+ label: "Transactions",
+ icon: "hero-banknotes",
+ path: "/admin/billing/transactions",
+ priority: 524,
+ level: :admin,
+ permission: "billing",
+ match: :prefix,
+ parent: :admin_billing
+ },
+ %Tab{
+ id: :admin_billing_subscriptions,
+ label: "Subscriptions",
+ icon: "hero-banknotes",
+ path: "/admin/billing/subscriptions",
+ priority: 525,
+ level: :admin,
+ permission: "billing",
+ match: :prefix,
+ parent: :admin_billing
+ },
+ %Tab{
+ id: :admin_billing_plans,
+ label: "Plans",
+ icon: "hero-banknotes",
+ path: "/admin/billing/plans",
+ priority: 526,
+ level: :admin,
+ permission: "billing",
+ match: :prefix,
+ parent: :admin_billing
+ },
+ %Tab{
+ id: :admin_billing_profiles,
+ label: "Billing Profiles",
+ icon: "hero-banknotes",
+ path: "/admin/billing/profiles",
+ priority: 527,
+ level: :admin,
+ permission: "billing",
+ match: :prefix,
+ parent: :admin_billing
+ },
+ %Tab{
+ id: :admin_billing_currencies,
+ label: "Currencies",
+ icon: "hero-banknotes",
+ path: "/admin/billing/currencies",
+ priority: 528,
+ level: :admin,
+ permission: "billing",
+ match: :prefix,
+ parent: :admin_billing
+ },
+ %Tab{
+ id: :admin_billing_providers,
+ label: "Payment Providers",
+ icon: "hero-banknotes",
+ path: "/admin/settings/billing/providers",
+ priority: 529,
+ level: :admin,
+ permission: "billing",
+ match: :prefix,
+ parent: :admin_billing
+ },
+ # Shop parent
+ %Tab{
+ id: :admin_shop,
+ label: "E-Commerce",
+ icon: "hero-shopping-bag",
+ path: "/admin/shop",
+ priority: 530,
+ level: :admin,
+ permission: "shop",
+ match: :exact,
+ group: :admin_modules,
+ subtab_display: :when_active,
+ highlight_with_subtabs: false
+ },
+ %Tab{
+ id: :admin_shop_dashboard,
+ label: "Dashboard",
+ icon: "hero-home",
+ path: "/admin/shop",
+ priority: 531,
+ level: :admin,
+ permission: "shop",
+ match: :exact,
+ parent: :admin_shop
+ },
+ %Tab{
+ id: :admin_shop_products,
+ label: "Products",
+ icon: "hero-cube",
+ path: "/admin/shop/products",
+ priority: 532,
+ level: :admin,
+ permission: "shop",
+ match: :prefix,
+ parent: :admin_shop
+ },
+ %Tab{
+ id: :admin_shop_categories,
+ label: "Categories",
+ icon: "hero-folder",
+ path: "/admin/shop/categories",
+ priority: 533,
+ level: :admin,
+ permission: "shop",
+ match: :prefix,
+ parent: :admin_shop
+ },
+ %Tab{
+ id: :admin_shop_shipping,
+ label: "Shipping",
+ icon: "hero-truck",
+ path: "/admin/shop/shipping",
+ priority: 534,
+ level: :admin,
+ permission: "shop",
+ match: :prefix,
+ parent: :admin_shop
+ },
+ %Tab{
+ id: :admin_shop_carts,
+ label: "Carts",
+ icon: "hero-shopping-cart",
+ path: "/admin/shop/carts",
+ priority: 535,
+ level: :admin,
+ permission: "shop",
+ match: :prefix,
+ parent: :admin_shop
+ },
+ %Tab{
+ id: :admin_shop_imports,
+ label: "CSV Import",
+ icon: "hero-cloud-arrow-up",
+ path: "/admin/shop/imports",
+ priority: 536,
+ level: :admin,
+ permission: "shop",
+ match: :prefix,
+ parent: :admin_shop
+ },
+ # Entities (with dynamic children)
+ %Tab{
+ id: :admin_entities,
+ label: "Entities",
+ icon: "hero-cube",
+ path: "/admin/entities",
+ priority: 540,
+ level: :admin,
+ permission: "entities",
+ match: :exact,
+ group: :admin_modules,
+ subtab_display: :when_active,
+ highlight_with_subtabs: false,
+ dynamic_children: &__MODULE__.entities_children/1
+ },
+ # AI parent
+ %Tab{
+ id: :admin_ai,
+ label: "AI",
+ icon: "hero-cpu-chip",
+ path: "/admin/ai",
+ priority: 550,
+ level: :admin,
+ permission: "ai",
+ match: :prefix,
+ group: :admin_modules,
+ subtab_display: :when_active,
+ highlight_with_subtabs: false
+ },
+ %Tab{
+ id: :admin_ai_endpoints,
+ label: "Endpoints",
+ icon: "hero-server-stack",
+ path: "/admin/ai/endpoints",
+ priority: 551,
+ level: :admin,
+ permission: "ai",
+ match: :prefix,
+ parent: :admin_ai
+ },
+ %Tab{
+ id: :admin_ai_prompts,
+ label: "Prompts",
+ icon: "hero-document-text",
+ path: "/admin/ai/prompts",
+ priority: 552,
+ level: :admin,
+ permission: "ai",
+ match: :prefix,
+ parent: :admin_ai
+ },
+ %Tab{
+ id: :admin_ai_usage,
+ label: "Usage",
+ icon: "hero-chart-bar",
+ path: "/admin/ai/usage",
+ priority: 553,
+ level: :admin,
+ permission: "ai",
+ match: :prefix,
+ parent: :admin_ai
+ },
+ # Sync parent
+ %Tab{
+ id: :admin_sync,
+ label: "Sync",
+ icon: "hero-arrows-right-left",
+ path: "/admin/sync",
+ priority: 560,
+ level: :admin,
+ permission: "sync",
+ match: :prefix,
+ group: :admin_modules,
+ subtab_display: :when_active,
+ highlight_with_subtabs: false
+ },
+ %Tab{
+ id: :admin_sync_overview,
+ label: "Overview",
+ icon: "hero-home",
+ path: "/admin/sync",
+ priority: 561,
+ level: :admin,
+ permission: "sync",
+ match: :exact,
+ parent: :admin_sync
+ },
+ %Tab{
+ id: :admin_sync_connections,
+ label: "Connections",
+ icon: "hero-link",
+ path: "/admin/sync/connections",
+ priority: 562,
+ level: :admin,
+ permission: "sync",
+ match: :prefix,
+ parent: :admin_sync
+ },
+ %Tab{
+ id: :admin_sync_history,
+ label: "History",
+ icon: "hero-clock",
+ path: "/admin/sync/history",
+ priority: 563,
+ level: :admin,
+ permission: "sync",
+ match: :prefix,
+ parent: :admin_sync
+ },
+ # DB
+ %Tab{
+ id: :admin_db,
+ label: "DB",
+ icon: "hero-table-cells",
+ path: "/admin/db",
+ priority: 570,
+ level: :admin,
+ permission: "db",
+ match: :exact,
+ group: :admin_modules
+ },
+ # Posts parent
+ %Tab{
+ id: :admin_posts,
+ label: "Posts",
+ icon: "hero-document-text",
+ path: "/admin/posts",
+ priority: 580,
+ level: :admin,
+ permission: "posts",
+ match: :prefix,
+ group: :admin_modules,
+ subtab_display: :when_active,
+ highlight_with_subtabs: false
+ },
+ %Tab{
+ id: :admin_posts_all,
+ label: "All Posts",
+ icon: "hero-document-text",
+ path: "/admin/posts",
+ priority: 581,
+ level: :admin,
+ permission: "posts",
+ match: :exact,
+ parent: :admin_posts
+ },
+ %Tab{
+ id: :admin_posts_groups,
+ label: "Groups",
+ icon: "hero-folder",
+ path: "/admin/posts/groups",
+ priority: 582,
+ level: :admin,
+ permission: "posts",
+ match: :prefix,
+ parent: :admin_posts
+ },
+ # Comments
+ %Tab{
+ id: :admin_comments,
+ label: "Comments",
+ icon: "hero-chat-bubble-left-right",
+ path: "/admin/comments",
+ priority: 590,
+ level: :admin,
+ permission: "comments",
+ match: :prefix,
+ group: :admin_modules
+ },
+ # Publishing (with dynamic children)
+ %Tab{
+ id: :admin_publishing,
+ label: "Publishing",
+ icon: "hero-document-text",
+ path: "/admin/publishing",
+ priority: 600,
+ level: :admin,
+ permission: "publishing",
+ match: :exact,
+ group: :admin_modules,
+ subtab_display: :when_active,
+ highlight_with_subtabs: false,
+ dynamic_children: &__MODULE__.publishing_children/1
+ },
+ # Jobs
+ %Tab{
+ id: :admin_jobs,
+ label: "Jobs",
+ icon: "hero-queue-list",
+ path: "/admin/jobs",
+ priority: 610,
+ level: :admin,
+ permission: "jobs",
+ match: :prefix,
+ group: :admin_modules
+ },
+ # Tickets
+ %Tab{
+ id: :admin_tickets,
+ label: "Tickets",
+ icon: "hero-ticket",
+ path: "/admin/tickets",
+ priority: 620,
+ level: :admin,
+ permission: "tickets",
+ match: :prefix,
+ group: :admin_modules
+ },
+ # Modules
+ %Tab{
+ id: :admin_modules_page,
+ label: "Modules",
+ icon: "hero-puzzle-piece",
+ path: "/admin/modules",
+ priority: 630,
+ level: :admin,
+ permission: "modules",
+ match: :exact,
+ group: :admin_modules
+ }
+ ]
+ end
+
+ @doc """
+ Returns settings admin tabs.
+ """
+ @spec settings_tabs() :: [Tab.t()]
+ def settings_tabs do
+ [
+ # Settings parent (visible if user has settings or any sub-module permission)
+ %Tab{
+ id: :admin_settings,
+ label: "Settings",
+ icon: "hero-cog-6-tooth",
+ path: "/admin/settings",
+ priority: 910,
+ level: :admin,
+ match: :exact,
+ group: :admin_system,
+ subtab_display: :when_active,
+ highlight_with_subtabs: false,
+ visible: &__MODULE__.settings_visible?/1
+ },
+ # Settings subtabs — core settings
+ %Tab{
+ id: :admin_settings_general,
+ label: "General",
+ icon: "hero-cog-6-tooth",
+ path: "/admin/settings",
+ priority: 911,
+ level: :admin,
+ permission: "settings",
+ match: :exact,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_organization,
+ label: "Organization",
+ icon: "hero-building-office",
+ path: "/admin/settings/organization",
+ priority: 912,
+ level: :admin,
+ permission: "settings",
+ match: :prefix,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_users,
+ label: "Users",
+ icon: "hero-users",
+ path: "/admin/settings/users",
+ priority: 913,
+ level: :admin,
+ permission: "settings",
+ match: :prefix,
+ parent: :admin_settings
+ },
+ # Settings subtabs — feature module settings
+ %Tab{
+ id: :admin_settings_referrals,
+ label: "Referrals",
+ icon: "hero-ticket",
+ path: "/admin/settings/referral-codes",
+ priority: 920,
+ level: :admin,
+ permission: "referrals",
+ match: :prefix,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_publishing,
+ label: "Publishing",
+ icon: "hero-document-text",
+ path: "/admin/settings/publishing",
+ priority: 921,
+ level: :admin,
+ permission: "publishing",
+ match: :prefix,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_posts,
+ label: "Posts",
+ icon: "hero-document-text",
+ path: "/admin/settings/posts",
+ priority: 922,
+ level: :admin,
+ permission: "posts",
+ match: :prefix,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_tickets,
+ label: "Tickets",
+ icon: "hero-ticket",
+ path: "/admin/settings/tickets",
+ priority: 923,
+ level: :admin,
+ permission: "tickets",
+ match: :prefix,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_comments,
+ label: "Comments",
+ icon: "hero-chat-bubble-left-right",
+ path: "/admin/settings/comments",
+ priority: 924,
+ level: :admin,
+ permission: "comments",
+ match: :prefix,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_emails,
+ label: "Emails",
+ icon: "hero-envelope",
+ path: "/admin/settings/emails",
+ priority: 925,
+ level: :admin,
+ permission: "emails",
+ match: :prefix,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_billing,
+ label: "Billing",
+ icon: "hero-banknotes",
+ path: "/admin/settings/billing",
+ priority: 926,
+ level: :admin,
+ permission: "billing",
+ match: :exact,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_shop,
+ label: "E-Commerce",
+ icon: "hero-shopping-bag",
+ path: "/admin/shop/settings",
+ priority: 927,
+ level: :admin,
+ permission: "shop",
+ match: :prefix,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_languages,
+ label: "Languages",
+ icon: "hero-language",
+ path: "/admin/settings/languages",
+ priority: 928,
+ level: :admin,
+ permission: "languages",
+ match: :prefix,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_legal,
+ label: "Legal",
+ icon: "hero-scale",
+ path: "/admin/settings/legal",
+ priority: 929,
+ level: :admin,
+ permission: "legal",
+ match: :prefix,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_seo,
+ label: "SEO",
+ icon: "hero-magnifying-glass-circle",
+ path: "/admin/settings/seo",
+ priority: 930,
+ level: :admin,
+ permission: "seo",
+ match: :prefix,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_sitemap,
+ label: "Sitemap",
+ icon: "hero-map",
+ path: "/admin/settings/sitemap",
+ priority: 931,
+ level: :admin,
+ permission: "sitemap",
+ match: :prefix,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_maintenance,
+ label: "Maintenance",
+ icon: "hero-wrench-screwdriver",
+ path: "/admin/settings/maintenance",
+ priority: 932,
+ level: :admin,
+ permission: "maintenance",
+ match: :prefix,
+ parent: :admin_settings
+ },
+ %Tab{
+ id: :admin_settings_media,
+ label: "Media",
+ icon: "hero-photo",
+ path: "/admin/settings/media",
+ priority: 933,
+ level: :admin,
+ permission: "media",
+ match: :prefix,
+ parent: :admin_settings,
+ subtab_display: :when_active,
+ highlight_with_subtabs: false
+ },
+ %Tab{
+ id: :admin_settings_media_dimensions,
+ label: "Dimensions",
+ icon: "hero-photo",
+ path: "/admin/settings/media/dimensions",
+ priority: 934,
+ level: :admin,
+ permission: "media",
+ match: :prefix,
+ parent: :admin_settings_media
+ },
+ %Tab{
+ id: :admin_settings_entities,
+ label: "Entities",
+ icon: "hero-cube",
+ path: "/admin/settings/entities",
+ priority: 935,
+ level: :admin,
+ permission: "entities",
+ match: :prefix,
+ parent: :admin_settings
+ }
+ ]
+ end
+
+ @doc """
+ Visibility function for the Settings parent tab.
+ Returns true if user has "settings" permission or any sub-module permission.
+ """
+ @settings_submodule_keys ~w(referrals publishing posts tickets comments emails billing shop languages legal seo sitemap maintenance media entities)
+ def settings_visible?(scope) do
+ Scope.has_module_access?(scope, "settings") or
+ Enum.any?(
+ @settings_submodule_keys,
+ &Scope.has_module_access?(scope, &1)
+ )
+ rescue
+ _ -> false
+ end
+
+ @doc """
+ Dynamic children function for Entities.
+ Returns a tab for each published entity.
+ Uses a lightweight query (no preloads) since the sidebar only needs name/icon/status.
+ """
+ @spec entities_children(map()) :: [Tab.t()]
+ def entities_children(_scope) do
+ if Code.ensure_loaded?(Entities) and
+ function_exported?(Entities, :list_entities, 0) do
+ # Lightweight query: select only fields needed for sidebar tabs, no preloads
+ import Ecto.Query, only: [from: 2]
+
+ entities =
+ from(e in Entities,
+ where: e.status == "published",
+ order_by: [desc: e.date_created],
+ select: %{
+ name: e.name,
+ display_name: e.display_name,
+ display_name_plural: e.display_name_plural,
+ icon: e.icon
+ }
+ )
+ |> PhoenixKit.RepoHelper.repo().all()
+
+ entities
+ |> Enum.with_index()
+ |> Enum.map(fn {entity, idx} ->
+ %Tab{
+ id: :"admin_entity_#{entity.name}",
+ label: entity.display_name_plural || entity.display_name,
+ icon: entity.icon || "hero-cube",
+ path: "/admin/entities/#{entity.name}/data",
+ priority: 541 + idx,
+ level: :admin,
+ permission: "entities",
+ match: :prefix,
+ parent: :admin_entities
+ }
+ end)
+ else
+ []
+ end
+ rescue
+ _ -> []
+ end
+
+ @doc """
+ Dynamic children function for Publishing.
+ Returns a tab for each publishing group from settings.
+ """
+ @spec publishing_children(map()) :: [Tab.t()]
+ def publishing_children(_scope) do
+ groups = load_publishing_groups()
+
+ groups
+ |> Enum.with_index()
+ |> Enum.map(fn {group, idx} ->
+ slug = group["slug"] || group[:slug] || ""
+ name = group["name"] || group[:name] || slug
+
+ %Tab{
+ id: :"admin_publishing_#{slug}",
+ label: name,
+ icon: "hero-document-text",
+ path: "/admin/publishing/#{slug}",
+ priority: 601 + idx,
+ level: :admin,
+ permission: "publishing",
+ match: :prefix,
+ parent: :admin_publishing
+ }
+ end)
+ rescue
+ _ -> []
+ end
+
+ defp load_publishing_groups do
+ # Use cached settings check instead of Publishing.enabled?() which
+ # calls get_setting (non-cached) and would add redundant DB queries.
+ # Check both new and legacy keys to match Publishing.enabled?() behavior.
+ publishing_enabled =
+ Settings.get_boolean_setting("publishing_enabled", false) or
+ Settings.get_boolean_setting("blogging_enabled", false)
+
+ if publishing_enabled do
+ json =
+ Settings.get_json_setting_cached("publishing_groups", nil) ||
+ Settings.get_json_setting_cached("blogging_blogs", nil)
+
+ case json do
+ %{"publishing_groups" => groups} when is_list(groups) -> normalize_groups(groups)
+ %{"blogs" => blogs} when is_list(blogs) -> normalize_groups(blogs)
+ list when is_list(list) -> normalize_groups(list)
+ _ -> []
+ end
+ else
+ []
+ end
+ rescue
+ _ -> []
+ end
+
+ defp normalize_groups(groups) do
+ Enum.map(groups, fn group ->
+ Enum.reduce(group, %{}, 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)
+ end
+end
diff --git a/lib/phoenix_kit/dashboard/dashboard.ex b/lib/phoenix_kit/dashboard/dashboard.ex
index 0cd6c166..1b4e9c61 100644
--- a/lib/phoenix_kit/dashboard/dashboard.ex
+++ b/lib/phoenix_kit/dashboard/dashboard.ex
@@ -190,6 +190,75 @@ defmodule PhoenixKit.Dashboard do
@spec unregister_tab(atom()) :: :ok
defdelegate unregister_tab(tab_id), to: Registry
+ # ============================================================================
+ # Admin Tab Management
+ # ============================================================================
+
+ @doc """
+ Gets all admin-level tabs, filtered by permission and module-enabled status.
+
+ ## Options
+
+ - `:scope` - The current authentication scope for permission filtering
+
+ ## Examples
+
+ tabs = PhoenixKit.Dashboard.get_admin_tabs(scope: scope)
+ """
+ @spec get_admin_tabs(keyword()) :: [Tab.t()]
+ defdelegate get_admin_tabs(opts \\ []), to: Registry
+
+ @doc """
+ Gets all user-level tabs, filtered by visibility and scope.
+
+ ## Options
+
+ - `:scope` - The current authentication scope for visibility filtering
+
+ ## Examples
+
+ tabs = PhoenixKit.Dashboard.get_user_tabs(scope: scope)
+ """
+ @spec get_user_tabs(keyword()) :: [Tab.t()]
+ defdelegate get_user_tabs(opts \\ []), to: Registry
+
+ @doc """
+ Registers admin tabs for an application namespace.
+
+ Automatically sets `level: :admin` on all tabs.
+
+ ## Examples
+
+ PhoenixKit.Dashboard.register_admin_tabs(:my_app, [
+ %{id: :admin_analytics, label: "Analytics", path: "/admin/analytics",
+ icon: "hero-chart-bar", permission: "dashboard"}
+ ])
+ """
+ @spec register_admin_tabs(atom(), [map() | Tab.t()]) :: :ok | {:error, term()}
+ def register_admin_tabs(namespace, tabs) when is_atom(namespace) and is_list(tabs) do
+ admin_tabs = Enum.map(tabs, fn tab -> Map.put(tab, :level, :admin) end)
+ register_tabs(namespace, admin_tabs)
+ end
+
+ @doc """
+ Updates an existing tab's attributes by ID.
+
+ ## Examples
+
+ PhoenixKit.Dashboard.update_tab(:admin_dashboard, %{label: "Home", icon: "hero-home"})
+ """
+ @spec update_tab(atom(), map()) :: :ok | {:error, :not_found}
+ defdelegate update_tab(tab_id, attrs), to: Registry
+
+ @doc """
+ Loads the default admin tabs into the registry.
+
+ Called automatically on Registry startup, but can be called manually
+ to reload defaults after changes.
+ """
+ @spec load_admin_defaults() :: :ok
+ defdelegate load_admin_defaults(), to: Registry
+
@doc """
Gets all registered tabs, sorted by priority.
diff --git a/lib/phoenix_kit/dashboard/registry.ex b/lib/phoenix_kit/dashboard/registry.ex
index 00065f2c..87c5fc4a 100644
--- a/lib/phoenix_kit/dashboard/registry.ex
+++ b/lib/phoenix_kit/dashboard/registry.ex
@@ -60,8 +60,11 @@ defmodule PhoenixKit.Dashboard.Registry do
use GenServer
- alias PhoenixKit.Dashboard.{Badge, Tab}
+ require Logger
+
+ alias PhoenixKit.Dashboard.{AdminTabs, Badge, Tab}
alias PhoenixKit.PubSubHelper
+ alias PhoenixKit.Users.Permissions
# Suppress warnings about optional modules (loaded conditionally)
@compile {:no_warn_undefined, PhoenixKit.Modules.Tickets}
@@ -158,24 +161,68 @@ defmodule PhoenixKit.Dashboard.Registry do
## Options
- `:scope` - The current scope (for visibility filtering)
+ - `:level` - Filter by tab level: `:admin`, `:user`, or nil for all
- `:path` - The current path (for active state detection)
- `:include_hidden` - Include tabs that would be hidden (default: false)
## Examples
Registry.get_tabs()
- Registry.get_tabs(scope: socket.assigns.phoenix_kit_current_scope)
+ Registry.get_tabs(scope: scope, level: :user)
+ Registry.get_tabs(scope: scope, level: :admin)
"""
@spec get_tabs(keyword()) :: [Tab.t()]
def get_tabs(opts \\ []) do
scope = opts[:scope]
+ level = opts[:level]
include_hidden = opts[:include_hidden] || false
all_tabs()
+ |> maybe_filter_level(level)
+ |> maybe_filter_enabled()
+ |> maybe_filter_permission(scope)
|> maybe_filter_visibility(scope, include_hidden)
|> sort_tabs()
end
+ @doc """
+ Gets admin-level tabs, filtered by permission and module-enabled status.
+
+ ## Options
+
+ - `:scope` - The current scope (for permission and visibility filtering)
+ - `:include_hidden` - Include tabs that would be hidden (default: false)
+ """
+ @spec get_admin_tabs(keyword()) :: [Tab.t()]
+ def get_admin_tabs(opts \\ []) do
+ get_tabs(Keyword.put(opts, :level, :admin))
+ end
+
+ @doc """
+ Gets user-level tabs.
+
+ ## Options
+
+ - `:scope` - The current scope (for visibility filtering)
+ - `:include_hidden` - Include tabs that would be hidden (default: false)
+ """
+ @spec get_user_tabs(keyword()) :: [Tab.t()]
+ def get_user_tabs(opts \\ []) do
+ get_tabs(Keyword.put(opts, :level, :user))
+ end
+
+ @doc """
+ Updates an existing tab's attributes.
+
+ ## Examples
+
+ Registry.update_tab(:admin_dashboard, %{label: "Home", icon: "hero-house"})
+ """
+ @spec update_tab(atom(), map()) :: :ok
+ def update_tab(tab_id, attrs) when is_atom(tab_id) and is_map(attrs) do
+ GenServer.call(__MODULE__, {:update_tab, tab_id, attrs})
+ end
+
@doc """
Gets a specific tab by ID.
"""
@@ -401,6 +448,16 @@ defmodule PhoenixKit.Dashboard.Registry do
GenServer.call(__MODULE__, :load_from_config)
end
+ @doc """
+ Loads admin default tabs.
+
+ Called during initialization.
+ """
+ @spec load_admin_defaults() :: :ok
+ def load_admin_defaults do
+ GenServer.call(__MODULE__, :load_admin_defaults)
+ end
+
# GenServer Callbacks
@impl true
@@ -408,11 +465,15 @@ defmodule PhoenixKit.Dashboard.Registry do
# Create ETS table for tab storage
:ets.new(@ets_table, [:named_table, :set, :public, read_concurrency: true])
- # Load defaults and config
+ # Load user dashboard defaults and config
load_defaults_internal()
load_from_config_internal()
- {:ok, %{namespaces: MapSet.new([:phoenix_kit])}}
+ # Load admin dashboard defaults and config
+ load_admin_defaults_internal()
+ load_admin_from_config_internal()
+
+ {:ok, %{namespaces: MapSet.new([:phoenix_kit, :phoenix_kit_admin])}}
end
@impl true
@@ -501,6 +562,39 @@ defmodule PhoenixKit.Dashboard.Registry do
{:reply, :ok, state}
end
+ @impl true
+ def handle_call(:load_admin_defaults, _from, state) do
+ load_admin_defaults_internal()
+ broadcast_refresh()
+ {:reply, :ok, state}
+ end
+
+ @impl true
+ def handle_call({:update_tab, tab_id, attrs}, _from, state) do
+ case get_tab(tab_id) do
+ nil ->
+ {:reply, :ok, state}
+
+ tab ->
+ updated_tab =
+ Enum.reduce(attrs, tab, fn
+ {:label, v}, acc -> %{acc | label: v}
+ {:icon, v}, acc -> %{acc | icon: v}
+ {:path, v}, acc -> %{acc | path: v}
+ {:priority, v}, acc -> %{acc | priority: v}
+ {:visible, v}, acc -> %{acc | visible: v}
+ {:permission, v}, acc -> %{acc | permission: v}
+ {:group, v}, acc -> %{acc | group: v}
+ {:metadata, v}, acc -> %{acc | metadata: Map.merge(acc.metadata, v)}
+ _, acc -> acc
+ end)
+
+ :ets.insert(@ets_table, {{:tab, tab_id}, updated_tab})
+ broadcast_update(updated_tab)
+ {:reply, :ok, state}
+ end
+ end
+
# Private helpers
defp all_tabs do
@@ -521,6 +615,60 @@ defmodule PhoenixKit.Dashboard.Registry do
Enum.sort_by(tabs, & &1.priority)
end
+ # Filter tabs by level. :admin returns admin+all, :user returns user+all
+ defp maybe_filter_level(tabs, nil), do: tabs
+
+ defp maybe_filter_level(tabs, :admin) do
+ Enum.filter(tabs, fn tab ->
+ level = Map.get(tab, :level, :user)
+ level in [:admin, :all]
+ end)
+ end
+
+ defp maybe_filter_level(tabs, :user) do
+ Enum.filter(tabs, fn tab ->
+ level = Map.get(tab, :level, :user)
+ level in [:user, :all]
+ end)
+ end
+
+ defp maybe_filter_level(tabs, _), do: tabs
+
+ # Filter out tabs whose associated module is disabled.
+ # Precomputes enabled state per unique permission key to avoid
+ # redundant DB queries (e.g., 5 "publishing" tabs = 1 query, not 5).
+ defp maybe_filter_enabled(tabs) do
+ enabled_cache =
+ tabs
+ |> Enum.map(& &1.permission)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+ |> Map.new(fn perm ->
+ enabled =
+ try do
+ Permissions.feature_enabled?(perm)
+ rescue
+ _ -> false
+ end
+
+ {perm, enabled}
+ end)
+
+ Enum.filter(tabs, fn tab ->
+ case tab.permission do
+ nil -> true
+ perm -> Map.get(enabled_cache, perm, true)
+ end
+ end)
+ end
+
+ # Filter tabs by permission access
+ defp maybe_filter_permission(tabs, nil), do: tabs
+
+ defp maybe_filter_permission(tabs, scope) do
+ Enum.filter(tabs, &Tab.permission_granted?(&1, scope))
+ end
+
defp clear_namespace_tabs(namespace) do
# Find and remove all tabs for this namespace
pattern = {{:namespace, namespace, :_}, :_}
@@ -760,4 +908,114 @@ defmodule PhoenixKit.Dashboard.Registry do
# credo:disable-for-next-line Credo.Check.Design.AliasUsage
defp call_shop_enabled, do: PhoenixKit.Modules.Shop.enabled?()
+
+ # --- Admin Tab Loading ---
+
+ defp load_admin_defaults_internal do
+ clear_namespace_tabs(:phoenix_kit_admin)
+
+ tabs = AdminTabs.default_tabs()
+ groups = AdminTabs.default_groups()
+
+ Enum.each(tabs, fn tab ->
+ :ets.insert(@ets_table, {{:tab, tab.id}, tab})
+ :ets.insert(@ets_table, {{:namespace, :phoenix_kit_admin, tab.id}, true})
+ end)
+
+ # Merge admin groups with existing groups
+ existing_groups = get_groups()
+ merged = merge_groups(existing_groups, groups)
+ :ets.insert(@ets_table, {:groups, merged})
+ end
+
+ defp load_admin_from_config_internal do
+ # Load legacy AdminDashboardCategories config and convert to admin-level tabs
+ load_legacy_admin_categories()
+
+ # Load new :admin_dashboard_tabs config (highest precedence)
+ case Application.get_env(:phoenix_kit, :admin_dashboard_tabs) do
+ nil ->
+ :ok
+
+ tabs when is_list(tabs) ->
+ Enum.each(tabs, fn tab_config ->
+ # Auto-set level to :admin for admin dashboard tabs
+ tab_config = Map.put_new(tab_config, :level, :admin)
+
+ case Tab.new(tab_config) do
+ {:ok, tab} ->
+ :ets.insert(@ets_table, {{:tab, tab.id}, tab})
+ :ets.insert(@ets_table, {{:namespace, :admin_config, tab.id}, true})
+
+ {:error, _reason} ->
+ :ok
+ end
+ end)
+ end
+ end
+
+ # Convert legacy AdminDashboardCategories to admin-level Tab structs
+ defp load_legacy_admin_categories do
+ alias PhoenixKit.Config.AdminDashboardCategories
+
+ categories = AdminDashboardCategories.get_categories()
+
+ if categories != [] do
+ Logger.info(
+ "[PhoenixKit] Legacy :admin_dashboard_categories config detected. " <>
+ "Consider migrating to :admin_dashboard_tabs format."
+ )
+
+ # Convert each category to admin-level tabs
+ categories
+ |> Enum.with_index()
+ |> Enum.each(fn {category, cat_idx} ->
+ # Create parent tab from category
+ cat_id = :"admin_custom_#{cat_idx}"
+
+ first_url =
+ case category.subsections do
+ [first | _] -> Map.get(first, :url, "/admin")
+ _ -> "/admin"
+ end
+
+ parent = %Tab{
+ id: cat_id,
+ label: category.title,
+ icon: category.icon || "hero-folder",
+ path: first_url,
+ priority: 700 + cat_idx * 10,
+ level: :admin,
+ match: :prefix,
+ group: :admin_modules,
+ subtab_display: :when_active,
+ highlight_with_subtabs: false
+ }
+
+ :ets.insert(@ets_table, {{:tab, parent.id}, parent})
+ :ets.insert(@ets_table, {{:namespace, :admin_legacy, parent.id}, true})
+
+ # Create child tabs from subsections
+ category.subsections
+ |> Enum.with_index()
+ |> Enum.each(fn {subsection, sub_idx} ->
+ child_id = :"admin_custom_#{cat_idx}_#{sub_idx}"
+
+ child = %Tab{
+ id: child_id,
+ label: subsection.title,
+ icon: subsection.icon || "hero-document-text",
+ path: subsection.url,
+ priority: 701 + cat_idx * 10 + sub_idx,
+ level: :admin,
+ match: :prefix,
+ parent: cat_id
+ }
+
+ :ets.insert(@ets_table, {{:tab, child.id}, child})
+ :ets.insert(@ets_table, {{:namespace, :admin_legacy, child.id}, true})
+ end)
+ end)
+ end
+ end
end
diff --git a/lib/phoenix_kit/dashboard/tab.ex b/lib/phoenix_kit/dashboard/tab.ex
index 6e98a491..c976e386 100644
--- a/lib/phoenix_kit/dashboard/tab.ex
+++ b/lib/phoenix_kit/dashboard/tab.ex
@@ -89,6 +89,8 @@ defmodule PhoenixKit.Dashboard.Tab do
"""
alias PhoenixKit.Dashboard.Badge
+ alias PhoenixKit.Users.Auth.Scope
+ alias PhoenixKit.Users.Permissions
@type match_type :: :exact | :prefix | :regex | (String.t() -> boolean())
@@ -98,6 +100,10 @@ defmodule PhoenixKit.Dashboard.Tab do
@type subtab_animation :: :none | :slide | :fade | :collapse
+ @type level :: :user | :admin | :all
+
+ @type dynamic_children_fn :: (map() -> [t()]) | nil
+
@type t :: %__MODULE__{
id: atom(),
label: String.t(),
@@ -106,6 +112,9 @@ defmodule PhoenixKit.Dashboard.Tab do
priority: integer(),
group: atom() | nil,
parent: atom() | nil,
+ level: level(),
+ permission: String.t() | nil,
+ dynamic_children: dynamic_children_fn(),
subtab_display: subtab_display(),
subtab_indent: String.t() | nil,
subtab_icon_size: String.t() | nil,
@@ -139,7 +148,10 @@ defmodule PhoenixKit.Dashboard.Tab do
:subtab_icon_size,
:subtab_text_size,
:subtab_animation,
+ :permission,
+ :dynamic_children,
priority: 500,
+ level: :user,
subtab_display: :when_active,
redirect_to_first_subtab: false,
highlight_with_subtabs: false,
@@ -207,6 +219,9 @@ defmodule PhoenixKit.Dashboard.Tab do
priority: get_attr(attrs, :priority) || 500,
group: get_attr(attrs, :group),
parent: get_attr(attrs, :parent),
+ level: parse_level(get_attr(attrs, :level)),
+ permission: get_attr(attrs, :permission),
+ dynamic_children: get_attr(attrs, :dynamic_children),
subtab_display: parse_subtab_display(get_attr(attrs, :subtab_display)),
subtab_indent: get_attr(attrs, :subtab_indent),
subtab_icon_size: get_attr(attrs, :subtab_icon_size),
@@ -436,6 +451,57 @@ defmodule PhoenixKit.Dashboard.Tab do
def visible?(_, _), do: true
+ @doc """
+ Checks if this is an admin-level tab.
+ """
+ @spec admin?(t()) :: boolean()
+ def admin?(%__MODULE__{level: :admin}), do: true
+ def admin?(_), do: false
+
+ @doc """
+ Checks if this is a user-level tab.
+ """
+ @spec user?(t()) :: boolean()
+ def user?(%__MODULE__{level: :user}), do: true
+ def user?(_), do: false
+
+ @doc """
+ Checks if permission is granted for this tab given a scope.
+
+ Returns true if:
+ - The tab has no permission requirement (permission is nil)
+ - The scope has module access for the tab's permission key
+ """
+ @spec permission_granted?(t(), map()) :: boolean()
+ def permission_granted?(%__MODULE__{permission: nil}, _scope), do: true
+
+ def permission_granted?(%__MODULE__{permission: permission}, scope)
+ when is_binary(permission) do
+ Scope.has_module_access?(scope, permission)
+ rescue
+ _ -> false
+ end
+
+ def permission_granted?(_, _), do: true
+
+ @doc """
+ Checks if the module associated with this tab is enabled.
+
+ Returns true if:
+ - The tab has no permission requirement (permission is nil)
+ - The feature module for the permission key is enabled
+ """
+ @spec module_enabled?(t()) :: boolean()
+ def module_enabled?(%__MODULE__{permission: nil}), do: true
+
+ def module_enabled?(%__MODULE__{permission: permission}) when is_binary(permission) do
+ Permissions.feature_enabled?(permission)
+ rescue
+ _ -> false
+ end
+
+ def module_enabled?(_), do: true
+
@doc """
Updates a tab's badge value.
"""
@@ -516,6 +582,15 @@ defmodule PhoenixKit.Dashboard.Tab do
end
end
+ defp parse_level(nil), do: :user
+ defp parse_level(:user), do: :user
+ defp parse_level(:admin), do: :admin
+ defp parse_level(:all), do: :all
+ defp parse_level("user"), do: :user
+ defp parse_level("admin"), do: :admin
+ defp parse_level("all"), do: :all
+ defp parse_level(_), do: :user
+
defp parse_match(:exact), do: :exact
defp parse_match(:prefix), do: :prefix
defp parse_match({:regex, regex}) when is_struct(regex, Regex), do: {:regex, regex}
diff --git a/lib/phoenix_kit_web/components/dashboard/admin_sidebar.ex b/lib/phoenix_kit_web/components/dashboard/admin_sidebar.ex
new file mode 100644
index 00000000..2f0ae7d6
--- /dev/null
+++ b/lib/phoenix_kit_web/components/dashboard/admin_sidebar.ex
@@ -0,0 +1,295 @@
+defmodule PhoenixKitWeb.Components.Dashboard.AdminSidebar do
+ @moduledoc """
+ Admin sidebar component for the PhoenixKit admin panel.
+
+ Renders the admin navigation using registry-driven Tab structs instead of
+ hardcoded HEEX. Supports:
+ - Permission-gated tabs (filtered by Registry)
+ - Module-enabled filtering (filtered by Registry)
+ - Dynamic children for Entities and Publishing
+ - Subtab expand/collapse
+ - Full reuse of the TabItem component for consistent rendering
+
+ ## Usage
+
+ <.admin_sidebar
+ current_path={@current_path}
+ scope={@phoenix_kit_current_scope}
+ locale={@current_locale}
+ />
+ """
+
+ use Phoenix.Component
+
+ alias PhoenixKit.Dashboard.{Registry, Tab}
+ alias PhoenixKitWeb.Components.Dashboard.TabItem
+
+ import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1]
+
+ @doc """
+ Renders the complete admin sidebar navigation.
+
+ ## Attributes
+
+ - `current_path` - The current URL path for active state detection
+ - `scope` - The current authentication scope for permission filtering
+ - `locale` - The current locale for path generation
+ - `class` - Additional CSS classes
+ """
+ attr :current_path, :string, default: "/admin"
+ attr :scope, :any, default: nil
+ attr :locale, :string, default: nil
+ attr :class, :string, default: ""
+
+ def admin_sidebar(assigns) do
+ # Get admin tabs, already filtered by level, permission, and module-enabled
+ # Expand dynamic children BEFORE active state so dynamic tabs get checked too
+ tabs =
+ Registry.get_admin_tabs(scope: assigns.scope)
+ |> expand_dynamic_children(assigns.scope)
+ |> add_active_state(assigns.current_path)
+
+ # Group tabs
+ grouped_tabs = group_tabs(tabs)
+ groups = Registry.get_groups()
+
+ assigns =
+ assigns
+ |> assign(:tabs, tabs)
+ |> assign(:grouped_tabs, grouped_tabs)
+ |> assign(:groups, groups)
+
+ ~H"""
+
+ """
+ end
+
+ attr :group, :map, required: true
+ attr :tabs, :list, required: true
+ attr :all_tabs, :list, required: true
+ attr :locale, :string, default: nil
+
+ defp admin_tab_group(assigns) do
+ ~H"""
+
+ <%= if @group[:label] do %>
+
+
+ <%= if @group[:icon] do %>
+ <.icon name={@group[:icon]} class="w-3.5 h-3.5" />
+ <% end %>
+ {@group[:label]}
+
+
+ <% end %>
+
+ <%= for tab <- filter_top_level(@tabs) do %>
+ <.admin_tab_with_subtabs
+ tab={tab}
+ all_tabs={@all_tabs}
+ locale={@locale}
+ />
+ <% end %>
+
+ """
+ end
+
+ attr :tab, :any, required: true
+ attr :all_tabs, :list, required: true
+ attr :locale, :string, default: nil
+
+ defp admin_tab_with_subtabs(assigns) do
+ subtabs = get_subtabs_for(assigns.tab.id, assigns.all_tabs)
+ # Check all descendants (not just direct children) for active state
+ descendant_active = any_descendant_active?(assigns.tab.id, assigns.all_tabs)
+
+ show_subtabs =
+ Tab.show_subtabs?(assigns.tab, assigns.tab.active) or descendant_active
+
+ display_tab = maybe_redirect_to_first_subtab(assigns.tab, subtabs)
+
+ highlight_with_subtabs = Map.get(assigns.tab, :highlight_with_subtabs, false)
+
+ parent_active =
+ if descendant_active and not highlight_with_subtabs do
+ false
+ else
+ assigns.tab.active
+ end
+
+ assigns =
+ assigns
+ |> assign(:subtabs, subtabs)
+ |> assign(:show_subtabs, show_subtabs)
+ |> assign(:has_subtabs, subtabs != [])
+ |> assign(:display_tab, display_tab)
+ |> assign(:parent_active, parent_active)
+
+ ~H"""
+
+
+
+ <%= if @has_subtabs and @show_subtabs do %>
+
+ <%= for subtab <- @subtabs do %>
+ <.admin_subtab_item
+ subtab={subtab}
+ parent_tab={@tab}
+ all_tabs={@all_tabs}
+ locale={@locale}
+ />
+ <% end %>
+
+ <% end %>
+
+ """
+ end
+
+ attr :subtab, :any, required: true
+ attr :parent_tab, :any, required: true
+ attr :all_tabs, :list, required: true
+ attr :locale, :string, default: nil
+
+ defp admin_subtab_item(assigns) do
+ children = get_subtabs_for(assigns.subtab.id, assigns.all_tabs)
+ child_active = any_descendant_active?(assigns.subtab.id, assigns.all_tabs)
+
+ show_children =
+ children != [] and
+ (Tab.show_subtabs?(assigns.subtab, assigns.subtab.active) or child_active)
+
+ highlight_with_subtabs = Map.get(assigns.subtab, :highlight_with_subtabs, false)
+
+ subtab_active =
+ if child_active and not highlight_with_subtabs do
+ false
+ else
+ assigns.subtab.active
+ end
+
+ assigns =
+ assigns
+ |> assign(:children, children)
+ |> assign(:show_children, show_children)
+ |> assign(:subtab_active, subtab_active)
+
+ ~H"""
+
+ <%= if @show_children do %>
+
+ <%= for child <- @children do %>
+
+ <% end %>
+
+ <% end %>
+ """
+ end
+
+ # --- Helpers ---
+
+ defp add_active_state(tabs, current_path) do
+ Enum.map(tabs, fn tab ->
+ Map.put(tab, :active, Tab.matches_path?(tab, current_path))
+ end)
+ end
+
+ defp expand_dynamic_children(tabs, scope) do
+ # Find tabs with dynamic_children and expand them
+ {parents_with_dynamic, other_tabs} =
+ Enum.split_with(tabs, fn tab ->
+ is_function(tab.dynamic_children, 1)
+ end)
+
+ dynamic_children =
+ Enum.flat_map(parents_with_dynamic, fn parent ->
+ children =
+ try do
+ parent.dynamic_children.(scope)
+ rescue
+ _ -> []
+ end
+
+ # Ensure children have parent set and correct level
+ Enum.map(children, fn child ->
+ child
+ |> Map.put(:parent, child.parent || parent.id)
+ |> Map.put(:level, :admin)
+ end)
+ end)
+
+ # Active state is applied after this function by add_active_state/2
+ other_tabs ++ parents_with_dynamic ++ dynamic_children
+ end
+
+ defp group_tabs(tabs) do
+ Enum.group_by(tabs, & &1.group)
+ end
+
+ defp sorted_groups(groups, grouped_tabs) do
+ group_ids_with_tabs = Map.keys(grouped_tabs) |> Enum.reject(&is_nil/1)
+
+ groups
+ |> Enum.filter(&(&1.id in group_ids_with_tabs))
+ |> Enum.sort_by(& &1.priority)
+ end
+
+ defp filter_top_level(tabs) do
+ Enum.filter(tabs, &Tab.top_level?/1)
+ end
+
+ defp get_subtabs_for(parent_id, all_tabs) do
+ Enum.filter(all_tabs, fn tab ->
+ tab.parent == parent_id
+ end)
+ |> Enum.sort_by(& &1.priority)
+ end
+
+ # Recursively checks if any descendant (children, grandchildren, etc.) is active
+ defp any_descendant_active?(parent_id, all_tabs) do
+ children = get_subtabs_for(parent_id, all_tabs)
+
+ Enum.any?(children, fn child ->
+ child.active or any_descendant_active?(child.id, all_tabs)
+ end)
+ end
+
+ defp maybe_redirect_to_first_subtab(%{redirect_to_first_subtab: true} = tab, [
+ first_subtab | _
+ ]) do
+ %{tab | path: first_subtab.path}
+ end
+
+ defp maybe_redirect_to_first_subtab(tab, _subtabs), do: tab
+end
diff --git a/lib/phoenix_kit_web/components/dashboard/sidebar.ex b/lib/phoenix_kit_web/components/dashboard/sidebar.ex
index ff8f9c53..2e996001 100644
--- a/lib/phoenix_kit_web/components/dashboard/sidebar.ex
+++ b/lib/phoenix_kit_web/components/dashboard/sidebar.ex
@@ -94,8 +94,11 @@ defmodule PhoenixKitWeb.Components.Dashboard.Sidebar do
# Load tabs if not provided
tabs =
case assigns.tabs do
- nil -> Registry.get_tabs_with_active(assigns.current_path, scope: assigns.scope)
- tabs -> add_active_state(tabs, assigns.current_path)
+ nil ->
+ Registry.get_tabs_with_active(assigns.current_path, scope: assigns.scope, level: :user)
+
+ tabs ->
+ add_active_state(tabs, assigns.current_path)
end
# Group tabs
@@ -381,7 +384,7 @@ defmodule PhoenixKitWeb.Components.Dashboard.Sidebar do
def mobile_navigation(assigns) do
tabs =
- Registry.get_tabs_with_active(assigns.current_path, scope: assigns.scope)
+ Registry.get_tabs_with_active(assigns.current_path, scope: assigns.scope, level: :user)
|> Enum.filter(&Tab.navigable?/1)
|> Enum.take(assigns.max_tabs)
@@ -471,7 +474,7 @@ defmodule PhoenixKitWeb.Components.Dashboard.Sidebar do
def mobile_fab_menu(assigns) do
all_tabs =
- Registry.get_tabs_with_active(assigns.current_path, scope: assigns.scope)
+ Registry.get_tabs_with_active(assigns.current_path, scope: assigns.scope, level: :user)
|> Enum.filter(&Tab.navigable?/1)
# Get only top-level tabs for rendering (subtabs handled separately)
@@ -636,7 +639,7 @@ defmodule PhoenixKitWeb.Components.Dashboard.Sidebar do
end
defp get_overflow_tabs(scope, shown_count) do
- Registry.get_tabs(scope: scope)
+ Registry.get_tabs(scope: scope, level: :user)
|> Enum.filter(&Tab.navigable?/1)
|> Enum.drop(shown_count)
end
diff --git a/lib/phoenix_kit_web/components/dashboard/tab_item.ex b/lib/phoenix_kit_web/components/dashboard/tab_item.ex
index 81b24fe6..13c825b9 100644
--- a/lib/phoenix_kit_web/components/dashboard/tab_item.ex
+++ b/lib/phoenix_kit_web/components/dashboard/tab_item.ex
@@ -315,7 +315,7 @@ defmodule PhoenixKitWeb.Components.Dashboard.TabItem do
base =
"flex items-center py-2 text-sm font-medium rounded-lg transition-all duration-200"
- # Subtabs use configurable indent, defaults to "pl-9 pr-3"
+ # Subtabs use configurable indent (default in :dashboard_subtab_style config)
# Now supports inline CSS values (px, rem, em, %) in addition to Tailwind classes
{padding_class, inline_style} =
if is_subtab do
@@ -361,8 +361,9 @@ defmodule PhoenixKitWeb.Components.Dashboard.TabItem do
end
# Resolves indent value to either a Tailwind class or inline style
- # Supports: Tailwind classes ("pl-9"), CSS values ("1.5rem", "24px"), integers (pixels), floats (rem)
- defp resolve_indent(nil), do: {:class, "pl-9"}
+ # Supports: Tailwind classes ("pl-4"), CSS values ("1.5rem", "24px"), integers (pixels), floats (rem)
+ # Default indent is configured in :dashboard_subtab_style in config.ex
+ defp resolve_indent(nil), do: {:class, "pl-3"}
defp resolve_indent(value) when is_integer(value) do
{:style, "padding-left: #{value}px"}
@@ -390,7 +391,7 @@ defmodule PhoenixKitWeb.Components.Dashboard.TabItem do
end
end
- defp resolve_indent(_value), do: {:class, "pl-9"}
+ defp resolve_indent(_value), do: {:class, "pl-3"}
defp icon_classes(_active, attention, is_subtab, subtab_style) do
# Subtabs use configurable icon size, defaults to "w-4 h-4"
@@ -419,21 +420,19 @@ defmodule PhoenixKitWeb.Components.Dashboard.TabItem do
global_style = PhoenixKit.Config.get(:dashboard_subtab_style, [])
%{
- indent: get_style_value(:subtab_indent, tab, parent_tab, global_style, :indent, "pl-9"),
- icon_size:
- get_style_value(:subtab_icon_size, tab, parent_tab, global_style, :icon_size, "w-4 h-4"),
- text_size:
- get_style_value(:subtab_text_size, tab, parent_tab, global_style, :text_size, "text-sm"),
- animation:
- get_style_value(:subtab_animation, tab, parent_tab, global_style, :animation, :none)
+ # Defaults come from :dashboard_subtab_style in config.ex
+ indent: get_style_value(:subtab_indent, tab, parent_tab, global_style, :indent),
+ icon_size: get_style_value(:subtab_icon_size, tab, parent_tab, global_style, :icon_size),
+ text_size: get_style_value(:subtab_text_size, tab, parent_tab, global_style, :text_size),
+ animation: get_style_value(:subtab_animation, tab, parent_tab, global_style, :animation)
}
end
- # Fallback chain: tab -> parent -> global -> default
- defp get_style_value(tab_key, tab, parent_tab, global_style, global_key, default) do
+ # Fallback chain: tab -> parent -> global config (defaults in config.ex)
+ defp get_style_value(tab_key, tab, parent_tab, global_style, global_key) do
Map.get(tab, tab_key) ||
(parent_tab && Map.get(parent_tab, tab_key)) ||
- Keyword.get(global_style, global_key, default)
+ Keyword.get(global_style, global_key)
end
defp subtab_animation_class(nil), do: nil
diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex
index a6eb02bb..fbb0e2b3 100644
--- a/lib/phoenix_kit_web/components/layout_wrapper.ex
+++ b/lib/phoenix_kit_web/components/layout_wrapper.ex
@@ -33,19 +33,18 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do
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.Publishing
+
alias PhoenixKit.Modules.SEO
alias PhoenixKit.ThemeConfig
alias PhoenixKit.Users.Auth.Scope
- alias PhoenixKit.Users.Permissions
alias PhoenixKit.Utils.PhoenixVersion
- alias PhoenixKit.Utils.Routes
@doc """
Renders content with the appropriate layout based on configuration and Phoenix version.
@@ -95,7 +94,6 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do
PhoenixKit.Settings.get_content_language()
end
end)
- |> assign_new(:publishing_groups, fn -> load_publishing_groups() 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+)
@@ -174,7 +172,6 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do
phoenix_kit_current_scope: assigns[:phoenix_kit_current_scope],
project_title: assigns[:project_title] || PhoenixKit.Settings.get_project_title(),
current_locale: assigns[:current_locale],
- publishing_groups: assigns[:publishing_groups] || [],
scope: assigns[:phoenix_kit_current_scope]
}
@@ -263,824 +260,13 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do
@@ -1345,97 +531,6 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do
end
end
- # Check if a user has access to a specific admin module/section
- defp module_accessible?(scope, module_key) do
- Scope.has_module_access?(scope, module_key)
- end
-
- # Settings section is visible if user has "settings" permission
- # or has permission for any module that has a settings sub-page
- @settings_submodule_keys ~w(referrals publishing posts tickets emails billing shop languages legal seo sitemap maintenance media entities)
- defp settings_section_visible?(scope) do
- module_accessible?(scope, "settings") or
- Enum.any?(@settings_submodule_keys, &module_accessible?(scope, &1))
- end
-
- # Returns the best settings href for the parent nav item.
- # If user has "settings" permission, go to General settings.
- # Otherwise, find the first accessible sub-module settings page.
- @settings_submodule_paths [
- {"referrals", "/admin/settings/referral-codes"},
- {"publishing", "/admin/settings/publishing"},
- {"posts", "/admin/settings/posts"},
- {"tickets", "/admin/settings/tickets"},
- {"emails", "/admin/settings/emails"},
- {"billing", "/admin/settings/billing"},
- {"shop", "/admin/shop/settings"},
- {"languages", "/admin/settings/languages"},
- {"legal", "/admin/settings/legal"},
- {"seo", "/admin/settings/seo"},
- {"sitemap", "/admin/settings/sitemap"},
- {"maintenance", "/admin/settings/maintenance"},
- {"media", "/admin/settings/media"},
- {"entities", "/admin/settings/entities"}
- ]
- defp settings_href(assigns, scope) do
- if module_accessible?(scope, "settings") do
- Routes.locale_aware_path(assigns, "/admin/settings")
- else
- enabled = Permissions.enabled_module_keys()
-
- case Enum.find(@settings_submodule_paths, fn {key, _} ->
- module_accessible?(scope, key) and MapSet.member?(enabled, key)
- end) do
- {_, path} -> Routes.locale_aware_path(assigns, path)
- nil -> Routes.locale_aware_path(assigns, "/admin/settings")
- end
- 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
- # Match 2-letter codes (en, es) or regional variants (en-US, es-ES, zh-CN)
- String.length(locale) <= 6 and String.match?(locale, ~r/^[a-z]{2}(-[A-Z]{2})?$/)
- end
-
- defp path_matches_any?(normalized_path, paths) do
- Enum.any?(paths, fn path ->
- # Exact match or path segment match (followed by / or query string)
- normalized_path == path ||
- String.starts_with?(normalized_path, path <> "/") ||
- String.starts_with?(normalized_path, path <> "?")
- 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
@@ -1600,96 +695,6 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do
end
end
- # Load publishing groups configuration with dual-key support (new key first, legacy fallback)
- defp load_publishing_groups do
- if Publishing.enabled?() do
- # Check new key first, then fallback to legacy keys
- json_settings = %{
- "publishing_groups" =>
- PhoenixKit.Settings.get_json_setting_cached("publishing_groups", nil),
- "blogging_blogs" => PhoenixKit.Settings.get_json_setting_cached("blogging_blogs", nil),
- "blogging_categories" =>
- PhoenixKit.Settings.get_json_setting_cached("blogging_categories", %{"types" => []})
- }
-
- extract_and_normalize_groups(json_settings)
- else
- []
- end
- end
-
- defp extract_and_normalize_groups(json_settings) do
- # Try new publishing_groups key first
- case json_settings["publishing_groups"] do
- %{"publishing_groups" => groups} when is_list(groups) ->
- normalize_blogs(groups)
-
- # Fallback to legacy blogging_blogs key
- _ ->
- extract_legacy_blogs(json_settings)
- end
- end
-
- defp extract_legacy_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
- # Migrate to new publishing_groups key
- PhoenixKit.Settings.update_json_setting("publishing_groups", %{"publishing_groups" => 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
-
# 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
@@ -1743,17 +748,4 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do
base_code = DialectMapper.extract_base(new_locale)
build_locale_url(current_path, base_code)
end
-
- # Check if custom category submenu should be open based on subsection URLs
- defp custom_category_submenu_open?(current_path, subsections)
- when is_binary(current_path) and is_list(subsections) do
- subsection_urls = Enum.map(subsections, & &1.url)
-
- current_path
- |> remove_phoenix_kit_prefix()
- |> remove_locale_prefix()
- |> path_matches_any?(subsection_urls)
- end
-
- defp custom_category_submenu_open?(_, _), do: false
end
diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex
index 84eafa3f..8bca5b1a 100644
--- a/lib/phoenix_kit_web/integration.ex
+++ b/lib/phoenix_kit_web/integration.ex
@@ -435,6 +435,9 @@ defmodule PhoenixKitWeb.Integration do
defmacro phoenix_kit_admin_routes(suffix) do
session_name = :"phoenix_kit_admin#{suffix}"
+ # Auto-generate routes for custom admin tabs that specify live_view
+ custom_admin_routes = compile_custom_admin_routes()
+
# Get external route module AST outside quote to avoid require/alias inside quote
emails_admin = EmailsRoutes.admin_routes()
@@ -758,6 +761,11 @@ defmodule PhoenixKitWeb.Integration do
unquote(tickets_admin)
unquote(publishing_admin)
unquote(referrals_admin)
+
+ # Custom admin routes from :admin_dashboard_tabs config
+ # Tabs with live_view: {Module, :action} get auto-generated routes
+ # in the shared admin live_session for seamless navigation
+ unquote_splicing(custom_admin_routes)
end
end
end
@@ -785,6 +793,47 @@ defmodule PhoenixKitWeb.Integration do
end
end
+ # Reads :admin_dashboard_tabs config at compile time and generates
+ # `live` route declarations for tabs that specify a `live_view` field.
+ # Returns a list of quoted expressions for use with unquote_splicing.
+ #
+ # ## Tab config example
+ #
+ # config :phoenix_kit, :admin_dashboard_tabs, [
+ # %{id: :admin_analytics, label: "Analytics", path: "/admin/analytics",
+ # live_view: {MyAppWeb.AnalyticsLive, :index}, permission: "dashboard"}
+ # ]
+ #
+ @doc false
+ def compile_custom_admin_routes do
+ case Application.get_env(:phoenix_kit, :admin_dashboard_tabs) do
+ tabs when is_list(tabs) ->
+ tabs
+ |> Enum.filter(fn tab ->
+ is_map(tab) and Map.has_key?(tab, :live_view) and
+ match?({module, _action} when is_atom(module), tab.live_view) and
+ match?({:module, _}, Code.ensure_compiled(elem(tab.live_view, 0)))
+ end)
+ |> Enum.map(fn tab ->
+ {module, action} = tab.live_view
+ path = tab[:path] || raise "Tab #{tab[:id]} has :live_view but no :path"
+
+ route_opts =
+ case tab[:id] do
+ nil -> []
+ id -> [as: id]
+ end
+
+ quote do
+ live unquote(path), unquote(module), unquote(action), unquote(route_opts)
+ end
+ end)
+
+ _ ->
+ []
+ end
+ end
+
# ============================================================================
# Route Scope Generators
# ============================================================================