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 # ============================================================================