diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs
index ffa9e4def..91759195b 100644
--- a/.dialyzer_ignore.exs
+++ b/.dialyzer_ignore.exs
@@ -4,7 +4,7 @@
{"lib/mix/tasks/phoenix_kit.doctor.ex", :unknown_function},
{"lib/mix/tasks/phoenix_kit.install.ex", :unknown_function},
{"lib/mix/tasks/phoenix_kit.update.ex", :unknown_function},
- {"lib/mix/tasks/phoenix_kit.gen.admin_page.ex", :unknown_function},
+ {"lib/mix/tasks/phoenix_kit.gen.admin.page.ex", :unknown_function},
{"lib/mix/tasks/phoenix_kit.gen.user.dashboard.ex", :unknown_function},
# Conditional compilation pattern match in update.ex (Code.ensure_loaded?)
{"lib/mix/tasks/phoenix_kit.update.ex", :pattern_match, 1},
@@ -21,7 +21,7 @@
{"lib/mix/tasks/phoenix_kit.gen.migration.ex", :callback_info_missing, 1},
{"lib/mix/tasks/phoenix_kit.install.ex", :callback_info_missing, 2},
{"lib/mix/tasks/phoenix_kit.update.ex", :callback_info_missing, 3},
- {"lib/mix/tasks/phoenix_kit.gen.admin_page.ex", :callback_info_missing},
+ {"lib/mix/tasks/phoenix_kit.gen.admin.page.ex", :callback_info_missing},
{"lib/mix/tasks/phoenix_kit.gen.user.dashboard.ex", :callback_info_missing},
{"lib/mix/tasks/phoenix_kit.modernize_layouts.ex", :callback_info_missing, 1},
{"lib/mix/tasks/phoenix_kit.assets.rebuild.ex", :callback_info_missing, 1},
diff --git a/lib/mix/tasks/phoenix_kit.gen.admin.page.ex b/lib/mix/tasks/phoenix_kit.gen.admin.page.ex
new file mode 100644
index 000000000..1401a9295
--- /dev/null
+++ b/lib/mix/tasks/phoenix_kit.gen.admin.page.ex
@@ -0,0 +1,396 @@
+defmodule Mix.Tasks.PhoenixKit.Gen.Admin.Page do
+ @moduledoc """
+ Igniter task that generates admin page with automatic route registration.
+
+ ## Usage
+
+ mix phoenix_kit.gen.admin.page "Reports Dashboard"
+
+ ## Arguments
+
+ - `title` - The display title for the page (e.g., "Reports Dashboard")
+
+ ## Options
+
+ - `--url` - The URL path for the page (optional, derived from title if not provided)
+ - `--category` - The category name (optional, defaults to "General")
+ - `--icon` - Heroicon name for the tab (optional, defaults to "hero-document-text")
+ - `--permission` - Permission key for parent tab (optional, defaults to "dashboard")
+ - `--category-icon` - Heroicon name for the category (optional, defaults to "hero-folder")
+
+ ## Parent/Child Tab Behavior
+
+ - First page in a category creates both parent and child tabs
+ - Subsequent pages in the same category only add the child tab
+ - Parent tab path points to the first child's URL
+ - Routes are automatically generated via the `live_view` field
+
+ ## Examples
+
+ # Simple - uses all defaults
+ mix phoenix_kit.gen.admin.page "Reports Dashboard"
+
+ # With custom category
+ mix phoenix_kit.gen.admin.page "User Management" --category="Users"
+
+ # With custom icon
+ mix phoenix_kit.gen.admin.page "Analytics" --icon="hero-chart-bar"
+
+ # Full control
+ mix phoenix_kit.gen.admin.page "Reports" --url="/admin/analytics/reports" --category="Analytics" --icon="hero-chart-bar"
+
+ """
+
+ @shortdoc "Generates admin page with automatic route registration"
+
+ use Igniter.Mix.Task
+
+ alias Igniter.Code.Common
+ alias Igniter.Project.Config
+ alias PhoenixKit.Install.IgniterHelpers
+ alias Sourceror.Zipper
+
+ @impl Igniter.Mix.Task
+ def info(_argv, _composing_task) do
+ %Igniter.Mix.Task.Info{
+ group: :phoenix_kit,
+ example: "mix phoenix_kit.gen.admin.page \"Reports Dashboard\"",
+ schema: [
+ url: :string,
+ category: :string,
+ icon: :string,
+ permission: :string,
+ category_icon: :string
+ ],
+ aliases: [u: :url, c: :category, i: :icon, p: :permission, ci: :category_icon]
+ }
+ end
+
+ @impl Igniter.Mix.Task
+ def igniter(igniter) do
+ opts = igniter.args.options
+ argv = igniter.args.argv
+
+ case parse_args(argv, opts) do
+ {:ok, {title, category, url}} ->
+ igniter
+ |> generate_admin_page(title, category, url, opts)
+
+ {:error, message} ->
+ Igniter.add_notice(igniter, """
+ ❌ Error: #{message}
+
+ Usage: mix phoenix_kit.gen.admin.page
+ Example: mix phoenix_kit.gen.admin.page "Reports Dashboard"
+ """)
+ end
+ end
+
+ @impl Mix.Task
+ def run(argv) do
+ if "--help" in argv or "-h" in argv do
+ Mix.shell().info("""
+ Generates admin page with automatic route registration.
+
+ Usage:
+
+ mix phoenix_kit.gen.admin.page "Reports Dashboard"
+
+ Arguments:
+
+ title - The display title for the page
+
+ Options:
+
+ --url - The URL path (optional, derived from title)
+ --category - The category name (optional, defaults to "General")
+ --icon - Heroicon name (optional, defaults to "hero-document-text")
+ --permission - Permission key (optional, defaults to "dashboard")
+ --category-icon - Heroicon name for category (optional, defaults to "hero-folder")
+
+ Examples:
+
+ # Simple
+ mix phoenix_kit.gen.admin.page "Reports Dashboard"
+
+ # With category
+ mix phoenix_kit.gen.admin.page "Users" --category="People"
+
+ # With URL
+ mix phoenix_kit.gen.admin.page "Reports" --url="/admin/analytics/reports"
+
+ Notes:
+ - Routes are auto-generated via live_view config
+ - First page in category creates parent tab
+ - Run: mix compile --force && restart server
+ """)
+
+ :ok
+ else
+ super(argv)
+ end
+ end
+
+ defp parse_args(argv, opts) do
+ positional_args = Enum.reject(argv, &String.starts_with?(&1, "--"))
+
+ case positional_args do
+ [title] ->
+ url = Keyword.get(opts, :url, slugify(title))
+ category = Keyword.get(opts, :category, "General")
+ {:ok, {title, category, url}}
+
+ [] ->
+ {:error, "not enough arguments. Expected: "}
+
+ _ ->
+ {:error, "invalid arguments"}
+ end
+ end
+
+ defp generate_admin_page(igniter, title, category, url, opts) do
+ icon = Keyword.get(opts, :icon, "hero-document-text")
+
+ cond do
+ !String.starts_with?(url, "/") ->
+ Igniter.add_issue(igniter, {:fatal, "URL must start with '/'", []})
+
+ byte_size(title) > 100 ->
+ Igniter.add_issue(igniter, {:fatal, "Title must be less than 100 characters", []})
+
+ true ->
+ igniter
+ |> create_page_live_view(title, category, url)
+ |> add_admin_tabs(title, category, url, icon, opts)
+ |> print_success_message(title, category, url)
+ end
+ end
+
+ defp create_page_live_view(igniter, title, category, url) do
+ category_module_name = Macro.camelize(String.replace(category, " ", "_"))
+ page_name = derive_page_name(title)
+
+ app_name = IgniterHelpers.get_parent_app_name(igniter)
+ web_module = IgniterHelpers.get_parent_app_module_web(igniter)
+
+ web_module_string =
+ web_module
+ |> to_string()
+ |> String.replace_prefix("Elixir.", "")
+
+ template_path =
+ case :code.priv_dir(:phoenix_kit) do
+ priv_dir when is_list(priv_dir) or is_binary(priv_dir) ->
+ Path.join(priv_dir, "templates/admin_category_page.ex")
+
+ _ ->
+ "priv/templates/admin_category_page.ex"
+ end
+
+ case File.read(template_path) do
+ {:ok, template_content} ->
+ rendered_content =
+ template_content
+ |> String.replace("<%= @web_module_prefix %>", web_module_string)
+ |> String.replace("<%= @page_name %>", to_string(page_name))
+ |> String.replace("<%= @page_title %>", to_string(title))
+ |> String.replace("<%= @url %>", to_string(url))
+ |> String.replace("<%= @category %>", category_module_name)
+
+ file_path = build_live_view_file_path(app_name, category, page_name)
+
+ Igniter.create_new_file(igniter, file_path, rendered_content, on_format: :skip)
+
+ {:error, reason} ->
+ igniter
+ |> Igniter.add_issue({:fatal, "Failed to read template file: #{reason}", []})
+ end
+ end
+
+ defp add_admin_tabs(igniter, title, category, url, icon, opts) do
+ web_module = IgniterHelpers.get_parent_app_module_web(igniter)
+ category_icon = Keyword.get(opts, :category_icon, "hero-folder")
+ permission = Keyword.get(opts, :permission, "dashboard")
+
+ parent_id = derive_parent_tab_id(category)
+ child_id = derive_child_tab_id(category, derive_page_name(title))
+
+ child_live_view_module = build_live_view_module(web_module, category, derive_page_name(title))
+
+ child_tab = %{
+ id: child_id,
+ label: title,
+ icon: icon,
+ path: url,
+ parent: parent_id,
+ permission: permission,
+ priority: calculate_child_priority(category, derive_page_name(title)),
+ live_view: {child_live_view_module, :index}
+ }
+
+ # Create parent tab (points to first child's URL)
+ parent_tab = %{
+ id: parent_id,
+ label: category,
+ icon: category_icon,
+ path: url,
+ permission: permission,
+ priority: calculate_parent_priority(category),
+ group: :admin_modules,
+ subtab_display: :when_active,
+ highlight_with_subtabs: false
+ }
+
+ # Use IgniterConfig to modify the config
+ Config.configure(
+ igniter,
+ "config.exs",
+ :phoenix_kit,
+ [:admin_dashboard_tabs],
+ [parent_tab, child_tab],
+ updater: fn zipper ->
+ case extract_current_value(zipper) do
+ {:ok, existing_tabs} when is_list(existing_tabs) ->
+ parent_exists? = Enum.any?(existing_tabs, fn t -> t[:id] == parent_id end)
+ child_exists? = Enum.any?(existing_tabs, fn t -> t[:id] == child_id end)
+
+ if child_exists? do
+ {:error,
+ "A page with ID #{inspect(child_id)} already exists in category '#{category}'"}
+ else
+ siblings =
+ Enum.filter(existing_tabs, fn t ->
+ t[:parent] == parent_id or (t[:id] == parent_id and parent_exists?)
+ end)
+
+ url_duplicate? =
+ Enum.any?(siblings, fn t ->
+ t[:path] == url and t[:id] != child_id
+ end)
+
+ if url_duplicate? do
+ {:error, "A page with URL '#{url}' already exists in category '#{category}'"}
+ else
+ updated_tabs =
+ if parent_exists? do
+ existing_tabs ++ [child_tab]
+ else
+ existing_tabs ++ [parent_tab, child_tab]
+ end
+
+ {:ok, Common.replace_code(zipper, updated_tabs)}
+ end
+ end
+
+ _ ->
+ {:ok, Common.replace_code(zipper, [parent_tab, child_tab])}
+ end
+ end
+ )
+ end
+
+ defp derive_parent_tab_id(category) do
+ category
+ |> String.downcase()
+ |> String.replace(" ", "_")
+ |> then(&:"admin_#{&1}")
+ end
+
+ defp derive_child_tab_id(category, page_name) do
+ category_slug = category |> String.downcase() |> String.replace(" ", "_")
+ page_slug = page_name |> String.downcase() |> String.replace(" ", "_")
+ :"admin_#{category_slug}_#{page_slug}"
+ end
+
+ defp derive_page_name(title) do
+ title
+ |> String.downcase()
+ |> String.replace(~r/[^a-z0-9\s]/, "")
+ |> String.replace(~r/\s+/, "_")
+ |> Macro.camelize()
+ end
+
+ defp build_live_view_module(web_module, category, page_name) do
+ category_module_name = Macro.camelize(String.replace(category, " ", "_"))
+ Module.concat([web_module, PhoenixKit, Admin, category_module_name, page_name])
+ end
+
+ defp calculate_parent_priority(category) do
+ category
+ |> String.downcase()
+ |> :erlang.phash2()
+ |> rem(90)
+ |> Kernel.+(700)
+ end
+
+ defp calculate_child_priority(category, page_name) do
+ parent_prio = calculate_parent_priority(category)
+
+ offset =
+ page_name
+ |> :erlang.phash2()
+ |> rem(9)
+ |> Kernel.+(1)
+
+ parent_prio + offset
+ end
+
+ defp slugify(title) do
+ title
+ |> String.downcase()
+ |> String.replace(~r/[^a-z0-9\s-]/, "")
+ |> String.replace(~r/\s+/, "-")
+ |> then(&"/admin/#{&1}")
+ end
+
+ # Extracts the current value from a zipper
+ defp extract_current_value(zipper) do
+ current_node = Zipper.node(zipper)
+
+ case Code.eval_quoted(current_node) do
+ {value, _binding} -> {:ok, value}
+ end
+ rescue
+ _ -> :error
+ end
+
+ defp print_success_message(igniter, title, category, url) do
+ web_module = IgniterHelpers.get_parent_app_module_web(igniter)
+
+ web_module_string =
+ web_module
+ |> to_string()
+ |> String.replace_prefix("Elixir.", "")
+
+ category_module_name = Macro.camelize(String.replace(category, " ", "_"))
+ page_name = derive_page_name(title)
+
+ page_module =
+ "#{web_module_string}.PhoenixKit.Admin.#{category_module_name}.#{page_name}"
+
+ Igniter.add_notice(igniter, """
+ ✅ Admin page generated!
+
+ Page: #{page_module}
+ Category: #{category}
+ URL: #{url}
+
+ 📝 Routes are auto-generated via live_view config
+
+ Then: mix compile --force && restart server
+ """)
+ end
+
+ defp build_live_view_file_path(app_name, category, page_name) do
+ web_path =
+ app_name
+ |> to_string()
+ |> Kernel.<>("_web")
+ |> String.downcase()
+
+ category_path = category |> String.replace(" ", "_") |> String.downcase()
+ file_name = String.downcase(page_name)
+
+ "lib/#{web_path}/phoenix_kit/admin/#{category_path}/#{file_name}.ex"
+ end
+end
diff --git a/lib/mix/tasks/phoenix_kit.gen.admin_page.ex b/lib/mix/tasks/phoenix_kit.gen.admin_page.ex
deleted file mode 100644
index 37fa32ea7..000000000
--- a/lib/mix/tasks/phoenix_kit.gen.admin_page.ex
+++ /dev/null
@@ -1,586 +0,0 @@
-defmodule Mix.Tasks.PhoenixKit.Gen.AdminPage do
- @moduledoc """
- Igniter task that generates admin pages with automatic route registration.
-
- ## Usage
-
- mix phoenix_kit.gen.admin_page MyCategory MyPage "Page Title" --url="/admin/my-page"
-
- ## Arguments
-
- - `category` - The category name (becomes parent tab)
- - `page_name` - The name for the page module (PascalCase)
- - `page_title` - The display title for the page
-
- ## Options
-
- - `--url` - The URL path for the page (required)
- - `--icon` - Heroicon name for the child tab (optional, defaults to "hero-document-text")
- - `--permission` - Permission key for parent tab (optional, defaults to "dashboard")
- - `--category-icon` - Heroicon name for the parent tab (optional, defaults to "hero-folder"). Only used when creating a new parent.
-
- ## Parent/Child Tab Behavior
-
- - First page in a category creates both parent and child tabs
- - Subsequent pages in the same category only add the child tab
- - Parent tab path points to the first child's URL
- - Routes are automatically generated via the `live_view` field
-
- """
-
- @shortdoc "Generates admin page with automatic route registration"
-
- use Igniter.Mix.Task
-
- alias Igniter.Code.Common
- alias Igniter.Project.Config
- alias PhoenixKit.Install.IgniterHelpers
- alias Sourceror.Zipper
-
- @impl Igniter.Mix.Task
- def info(_argv, _composing_task) do
- %Igniter.Mix.Task.Info{
- group: :phoenix_kit,
- example:
- "mix phoenix_kit.gen.admin_page Analytics Reports \"Reports Dashboard\" --url=\"/admin/analytics/reports\"",
- schema: [
- url: :string,
- icon: :string,
- permission: :string,
- category_icon: :string
- ],
- aliases: [u: :url, i: :icon, p: :permission, ci: :category_icon]
- }
- end
-
- @impl Igniter.Mix.Task
- def igniter(igniter) do
- # Get options and arguments from igniter context
- opts = igniter.args.options
- argv = igniter.args.argv
-
- case parse_args(argv, opts) do
- {:ok, {category, page_name, page_title}} ->
- igniter
- |> generate_admin_page(category, page_name, page_title, opts)
-
- {:error, message} ->
- Igniter.add_notice(igniter, """
- ❌ Error: #{message}
-
- Usage: mix phoenix_kit.gen.admin_page --url=
- Example: mix phoenix_kit.gen.admin_page Analytics Reports "Reports Dashboard" --url="/admin/analytics/reports"
- """)
- end
- end
-
- @impl Mix.Task
- def run(argv) do
- # Handle --help flag manually
- if "--help" in argv or "-h" in argv do
- Mix.shell().info("""
- Generates admin page with automatic route registration.
-
- Usage:
-
- mix phoenix_kit.gen.admin_page MyCategory MyPage "Page Title" --url="/admin/my-page"
-
- Arguments:
-
- category - The category name (becomes parent tab)
- page_name - The name for the page module (PascalCase)
- page_title - The display title for the page
-
- Options:
-
- --url - The URL path for the page (required)
- --icon - Heroicon name for the child tab (optional, defaults to "hero-document-text")
- --permission - Permission key for parent tab (optional, defaults to "dashboard")
- --category-icon - Heroicon name for the parent tab (optional, defaults to "hero-folder")
-
- Example:
-
- mix phoenix_kit.gen.admin_page Analytics Reports "Reports Dashboard" \\
- --url="/admin/analytics/reports"
-
- Parent/Child Tab Behavior:
-
- - First page in a category creates both parent and child tabs
- - Subsequent pages in the same category only add the child tab
- - Parent tab path points to the first child's URL
- - Routes are automatically generated via the live_view field
- - After generation, run: mix compile --force
- """)
-
- :ok
- else
- # Delegate to Igniter.Mix.Task for standard execution
- super(argv)
- end
- end
-
- defp parse_args(argv, opts) do
- # Filter out option arguments (starting with --) from argv
- positional_args = Enum.reject(argv, &String.starts_with?(&1, "--"))
-
- case positional_args do
- [category, page_name, page_title] ->
- url = Keyword.get(opts, :url)
-
- if url do
- {:ok, {category, page_name, page_title}}
- else
- {:error, "--url option is required"}
- end
-
- args when length(args) < 3 ->
- {:error, "not enough arguments"}
-
- _ ->
- {:error, "invalid arguments"}
- end
- end
-
- defp generate_admin_page(igniter, category, page_name, page_title, opts) do
- url = Keyword.get(opts, :url)
- icon = Keyword.get(opts, :icon, "hero-document-text")
-
- # Validate inputs
- cond do
- !url ->
- Igniter.add_issue(igniter, {:fatal, "--url option is required", []})
-
- !String.starts_with?(url, "/") ->
- Igniter.add_issue(igniter, {:fatal, "URL must start with '/'", []})
-
- byte_size(page_title) > 100 ->
- Igniter.add_issue(igniter, {:fatal, "Page title must be less than 100 characters", []})
-
- true ->
- igniter
- |> create_template_based_live_view(page_name, page_title, url, category)
- |> add_admin_tabs(category, page_name, page_title, url, icon, opts)
- |> print_success_message(category, page_name, page_title, url)
- end
- end
-
- defp create_template_based_live_view(igniter, page_name, page_title, url, category) do
- # Create nested module name with category
- category_module_name = Macro.camelize(String.replace(category, " ", "_"))
-
- app_name = IgniterHelpers.get_parent_app_name(igniter)
- web_module = IgniterHelpers.get_parent_app_module_web(igniter)
-
- web_module_string =
- web_module
- |> to_string()
- |> String.replace_prefix("Elixir.", "")
-
- # First, create the page LiveView
- igniter =
- create_page_live_view(
- igniter,
- app_name,
- web_module_string,
- category_module_name,
- page_name,
- page_title,
- url,
- category
- )
-
- # Then, create the category index LiveView
- category_slug = String.downcase(category |> String.replace(" ", "_"))
- category_url = "/admin/#{category_slug}"
-
- create_category_index_live_view(
- igniter,
- app_name,
- web_module_string,
- category_module_name,
- category,
- category_url
- )
- end
-
- defp create_page_live_view(
- igniter,
- app_name,
- web_module_string,
- category_module_name,
- page_name,
- page_title,
- url,
- category
- ) do
- module_name = "Live.Admin.#{category_module_name}.#{page_name}"
-
- # Read the EEx template file
- template_path =
- case :code.priv_dir(:phoenix_kit) do
- priv_dir when is_list(priv_dir) or is_binary(priv_dir) ->
- Path.join(priv_dir, "templates/admin_category_page.ex")
-
- _ ->
- "priv/templates/admin_category_page.ex"
- end
-
- case File.read(template_path) do
- {:ok, template_content} ->
- _full_module_name = Module.concat([web_module_string, "PhoenixKit", module_name])
-
- # Use string replacement to avoid EEx/HEEX conflicts
- rendered_content =
- template_content
- |> String.replace("<%= @web_module_prefix %>", web_module_string)
- |> String.replace("<%= @page_name %>", to_string(page_name))
- |> String.replace("<%= @page_title %>", to_string(page_title))
- |> String.replace("<%= @url %>", to_string(url))
- |> String.replace(
- "<%= @category %>",
- category_module_name
- )
-
- # Build the correct path
- file_path = build_live_view_file_path(app_name, category, page_name)
-
- # Use create_new_file to avoid module parsing that might corrupt HEEX syntax
- # Also skip formatting to preserve HEEX template syntax
- Igniter.create_new_file(igniter, file_path, rendered_content, on_format: :skip)
-
- {:error, reason} ->
- igniter
- |> Igniter.add_issue({:fatal, "Failed to read template file: #{reason}", []})
- end
- end
-
- defp create_category_index_live_view(
- igniter,
- app_name,
- web_module_string,
- category_module_name,
- category,
- category_url
- ) do
- # Read the category index template file
- template_path =
- case :code.priv_dir(:phoenix_kit) do
- priv_dir when is_list(priv_dir) or is_binary(priv_dir) ->
- Path.join(priv_dir, "templates/admin_category_index_page.ex")
-
- _ ->
- "priv/templates/admin_category_index_page.ex"
- end
-
- case File.read(template_path) do
- {:ok, template_content} ->
- # Use string replacement to avoid EEx/HEEX conflicts
- rendered_content =
- template_content
- |> String.replace("<%= @web_module_prefix %>", web_module_string)
- |> String.replace("<%= @category %>", category_module_name)
- |> String.replace("<%= @url %>", to_string(category_url))
-
- # Build the correct path for the index file
- category_slug = String.downcase(category |> String.replace(" ", "_"))
-
- web_path =
- app_name
- |> to_string()
- |> Kernel.<>("_web")
- |> String.downcase()
-
- file_path = "lib/#{web_path}/phoenix_kit/live/admin/#{category_slug}/index.ex"
-
- # Use create_new_file to avoid module parsing that might corrupt HEEX syntax
- Igniter.create_new_file(igniter, file_path, rendered_content, on_format: :skip)
-
- {:error, reason} ->
- igniter
- |> Igniter.add_issue({:fatal, "Failed to read template file: #{reason}", []})
- end
- end
-
- defp add_admin_tabs(igniter, category, page_name, page_title, url, icon, opts) do
- web_module = IgniterHelpers.get_parent_app_module_web(igniter)
- category_icon = Keyword.get(opts, :category_icon, "hero-folder")
- permission = Keyword.get(opts, :permission, "dashboard")
-
- parent_id = derive_parent_tab_id(category)
- child_id = derive_child_tab_id(category, page_name)
-
- # Build the full LiveView module path for child page
- child_live_view_module = build_live_view_module(web_module, category, page_name)
-
- # Build the category index LiveView module path for parent
- parent_live_view_module = build_category_index_module(web_module, category)
-
- # Calculate parent URL from category name
- category_slug = String.downcase(category |> String.replace(" ", "_"))
- parent_url = "/admin/#{category_slug}"
-
- # Create child tab config
- child_tab = %{
- id: child_id,
- label: page_title,
- icon: icon,
- path: url,
- parent: parent_id,
- priority: calculate_child_priority(category, page_name),
- live_view: {child_live_view_module, :index}
- }
-
- # Use IgniterConfig to modify the config
- Config.configure(
- igniter,
- "config.exs",
- :phoenix_kit,
- [:admin_dashboard_tabs],
- # Default value if config doesn't exist
- [
- create_parent_tab(
- category,
- parent_id,
- parent_url,
- parent_live_view_module,
- category_icon,
- permission
- ),
- child_tab
- ],
- updater: fn zipper ->
- case extract_current_value(zipper) do
- {:ok, existing_tabs} when is_list(existing_tabs) ->
- # Check if parent tab exists
- parent_exists? = Enum.any?(existing_tabs, fn t -> t[:id] == parent_id end)
-
- # Validate: Check for duplicate child tab ID
- child_exists? = Enum.any?(existing_tabs, fn t -> t[:id] == child_id end)
-
- if child_exists? do
- {:error,
- "A page with ID #{inspect(child_id)} already exists in category '#{category}'. " <>
- "Use a different page name."}
- else
- # Get existing children of this parent (siblings)
- siblings =
- Enum.filter(existing_tabs, fn t ->
- t[:parent] == parent_id or (t[:id] == parent_id and parent_exists?)
- end)
-
- # Validate: Check for duplicate URL in the same category
- url_duplicate? =
- Enum.any?(siblings, fn t ->
- t[:path] == url and t[:id] != child_id
- end)
-
- if url_duplicate? do
- {:error,
- "A page with URL '#{url}' already exists in category '#{category}'. " <>
- "Use a different URL."}
- else
- # Validate: Check for duplicate label in the same category
- label_duplicate? =
- Enum.any?(siblings, fn t ->
- t[:label] == page_title and t[:id] != child_id
- end)
-
- if label_duplicate? do
- {:error,
- "A page with label '#{page_title}' already exists in category '#{category}'. " <>
- "Use a different page title."}
- else
- updated_tabs =
- if parent_exists? do
- # Parent exists, just add child
- existing_tabs ++ [child_tab]
- else
- # Create parent tab with its own index page
- parent_tab =
- create_parent_tab(
- category,
- parent_id,
- parent_url,
- parent_live_view_module,
- category_icon,
- permission
- )
-
- existing_tabs ++ [parent_tab, child_tab]
- end
-
- {:ok, Common.replace_code(zipper, updated_tabs)}
- end
- end
- end
-
- _ ->
- # Config doesn't exist, create with parent and child
- {:ok,
- Common.replace_code(
- zipper,
- [
- create_parent_tab(
- category,
- parent_id,
- parent_url,
- parent_live_view_module,
- category_icon,
- permission
- ),
- child_tab
- ]
- )}
- end
- end
- )
- end
-
- defp create_parent_tab(category, parent_id, path, live_view_module, icon, permission) do
- %{
- id: parent_id,
- label: category,
- icon: icon,
- path: path,
- permission: permission,
- priority: calculate_parent_priority(category),
- group: :admin_modules,
- subtab_display: :when_active,
- highlight_with_subtabs: false,
- live_view: {live_view_module, :index}
- }
- end
-
- defp derive_parent_tab_id(category) do
- category
- |> String.downcase()
- |> String.replace(" ", "_")
- |> then(&:"admin_#{&1}")
- end
-
- defp derive_child_tab_id(category, page_name) do
- category_slug = category |> String.downcase() |> String.replace(" ", "_")
- page_slug = page_name |> String.downcase() |> String.replace(" ", "_")
- :"admin_#{category_slug}_#{page_slug}"
- end
-
- defp build_live_view_module(web_module, category, page_name) do
- category_module_name = Macro.camelize(String.replace(category, " ", "_"))
- Module.concat([web_module, PhoenixKit, Live, Admin, category_module_name, page_name])
- end
-
- defp build_category_index_module(web_module, category) do
- category_module_name = Macro.camelize(String.replace(category, " ", "_"))
- Module.concat([web_module, PhoenixKit, Live, Admin, category_module_name, Index])
- end
-
- defp calculate_parent_priority(category) do
- # Hash category to get a stable priority between 700-790
- category
- |> String.downcase()
- |> :erlang.phash2()
- |> rem(90)
- |> Kernel.+(700)
- end
-
- defp calculate_child_priority(category, page_name) do
- # Parent priority + 1-9 based on page name hash
- parent_prio = calculate_parent_priority(category)
-
- offset =
- page_name
- |> String.downcase()
- |> :erlang.phash2()
- |> rem(9)
- |> Kernel.+(1)
-
- parent_prio + offset
- end
-
- # Extracts the current value from a zipper
- defp extract_current_value(zipper) do
- current_node = Zipper.node(zipper)
-
- case Code.eval_quoted(current_node) do
- {value, _binding} -> {:ok, value}
- end
- rescue
- _ -> :error
- end
-
- defp print_success_message(igniter, category, page_name, _page_title, url) do
- web_module_name = IgniterHelpers.get_parent_app_module_web_string(igniter)
-
- # Generate the full module name for display
- category_module_name = Macro.camelize(String.replace(category, " ", "_"))
-
- page_module_name =
- "#{web_module_name}.PhoenixKit.Live.Admin.#{category_module_name}.#{page_name}"
-
- index_module_name =
- "#{web_module_name}.PhoenixKit.Live.Admin.#{category_module_name}.Index"
-
- category_slug = String.downcase(category |> String.replace(" ", "_"))
- category_url = "/admin/#{category_slug}"
-
- parent_id = derive_parent_tab_id(category)
- child_id = derive_child_tab_id(category, page_name)
-
- Igniter.add_notice(igniter, """
- ✅ Admin page generated successfully!
-
- Page Created: #{page_module_name}
- Index Created: #{index_module_name}
- Added to category: #{category}
- Page URL: #{url}
- Category URL: #{category_url}
-
- What was done:
- 1. ✓ Created category index LiveView at #{category_url}
- 2. ✓ Created page LiveView at #{url}
- 3. ✓ Added parent tab (#{parent_id}) and child tab (#{child_id}) to :admin_dashboard_tabs
-
- 📝 Important: Routes are automatically generated
-
- Routes are automatically generated via the live_view field in the config.
- No manual router configuration is needed.
-
- Next steps:
- 1. Force recompile: mix compile --force
- 2. Restart your server
- 3. Visit #{category_url} for the category index
- 4. Visit #{url} for the page
- 5. Implement the page functionality in the LiveView modules
- 6. Customize the page content as needed
-
- Parent/Child Tab Structure:
- - Parent tab: #{category} (#{parent_id})
- - Child tab: #{page_name} (#{child_id})
- - Parent path points to first child's URL
- - Subsequent pages in the same category will only add child tabs
- """)
- end
-
- # Builds the full file path for the LiveView template
- defp build_live_view_file_path(app_name, category, page_name) do
- # Use the app_name to get the correct web directory name
- # E.g., phoenix_kit_parent_project -> phoenix_kit_parent_project_web
- web_path =
- app_name
- |> to_string()
- |> Kernel.<>("_web")
- |> String.downcase()
-
- # Convert category to path-safe format
- category_path =
- category
- |> String.replace(" ", "_")
- |> String.downcase()
-
- # Convert page name to lowercase for the file
- file_name = String.downcase(page_name)
-
- # Return the full file path
- "lib/#{web_path}/phoenix_kit/live/admin/#{category_path}/#{file_name}.ex"
- end
-end
diff --git a/lib/mix/tasks/phoenix_kit.gen.user.dashboard.ex b/lib/mix/tasks/phoenix_kit.gen.user.dashboard.ex
index 5b7a7617b..c71e8c162 100644
--- a/lib/mix/tasks/phoenix_kit.gen.user.dashboard.ex
+++ b/lib/mix/tasks/phoenix_kit.gen.user.dashboard.ex
@@ -17,6 +17,7 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do
- `--icon` - Heroicon name for the tab (optional, defaults to "hero-document")
- `--description` - Brief description for the tab (optional)
- `--category-icon` - Heroicon name for the category (optional, defaults to "hero-folder"). Only used when creating a new category.
+ - `--index` - Generate as main dashboard index (skips config, for overriding /dashboard)
## Examples
@@ -32,6 +33,9 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do
# Full control
mix phoenix_kit.gen.user.dashboard "Example" --url="/custom/path" --icon="hero-user"
+ # Generate as main dashboard index (overrides /dashboard)
+ mix phoenix_kit.gen.user.dashboard "Dashboard" --url="/dashboard" --index --description="Welcome"
+
"""
@shortdoc "Generates user dashboard page with tab configuration"
@@ -51,7 +55,8 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do
category: :string,
icon: :string,
description: :string,
- category_icon: :string
+ category_icon: :string,
+ index: :boolean
],
aliases: [u: :url, c: :category, i: :icon, d: :description, ci: :category_icon]
}
@@ -67,7 +72,7 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do
{:ok, {tab_title, category, url}} ->
igniter
|> create_live_view(tab_title, url, opts)
- |> add_dashboard_tab(category, tab_title, url, opts)
+ |> maybe_add_dashboard_tab(category, tab_title, url, opts)
{:error, message} ->
Igniter.add_notice(igniter, """
@@ -101,6 +106,7 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do
--icon - Heroicon name for the tab (optional, defaults to "hero-document")
--description - Brief description for the tab (optional)
--category-icon - Heroicon name for the category (optional, defaults to "hero-folder")
+ --index - Generate as main dashboard index (skips config, for overriding /dashboard)
Examples:
@@ -116,10 +122,13 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do
# Full control
mix phoenix_kit.gen.user.dashboard "Example" --url="/custom/path" --icon="hero-user"
+ # Generate as main dashboard index (overrides /dashboard)
+ mix phoenix_kit.gen.user.dashboard "Dashboard" --url="/dashboard" --index --description="Welcome"
+
Notes:
- Creates a LiveView file for the dashboard page
- - Adds tab configuration to config/config.exs under :user_dashboard_categories
+ - Adds tab configuration to config/config.exs under :user_dashboard_categories (unless --index)
- After adding the route, run: mix compile --force
""")
@@ -218,6 +227,17 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do
end
end
+ defp maybe_add_dashboard_tab(igniter, category, tab_title, url, opts) do
+ is_index = Keyword.get(opts, :index, false)
+
+ if is_index do
+ # Skip adding to config for index pages
+ print_index_success_message(igniter, tab_title, url)
+ else
+ add_dashboard_tab(igniter, category, tab_title, url, opts)
+ end
+ end
+
defp update_dashboard_categories_config(
igniter,
category,
@@ -247,7 +267,6 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do
end
defp print_success_message(igniter, category, tab_title, url) do
- app_name = IgniterHelpers.get_parent_app_name(igniter)
web_module = IgniterHelpers.get_parent_app_module_web(igniter)
web_module_string =
@@ -259,20 +278,49 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do
live_view_module = "#{web_module_string}.PhoenixKit.Dashboard.#{page_name}"
Igniter.add_notice(igniter, """
- ✅ Dashboard page generated successfully!
+ ✅ Dashboard page generated!
- Page Created: #{live_view_module}
+ Page: #{live_view_module}
Tab: #{tab_title}
Category: #{category}
URL: #{url}
- What was done:
- 1. ✓ Created LiveView file at #{build_live_view_file_path(app_name, page_name)}
- 2. ✓ Added tab to user dashboard configuration in config/config.exs
+ 📝 Add route:
- 📝 Next Steps:
+ scope "/" do
+ pipe_through :browser
- 1. Add the route to your router (inside a dashboard live_session):
+ live_session :user_dashboard,
+ on_mount: [
+ {PhoenixKitWeb.Users.Auth, :phoenix_kit_ensure_authenticated_scope},
+ {PhoenixKitWeb.Dashboard.ContextProvider, :default}
+ ] do
+ live "#{url}", #{live_view_module}, :index
+ end
+ end
+
+ Then: mix compile --force && restart server
+ """)
+ end
+
+ defp print_index_success_message(igniter, tab_title, url) do
+ web_module = IgniterHelpers.get_parent_app_module_web(igniter)
+
+ web_module_string =
+ web_module
+ |> to_string()
+ |> String.replace_prefix("Elixir.", "")
+
+ page_name = camelize(tab_title)
+ live_view_module = "#{web_module_string}.PhoenixKit.Dashboard.#{page_name}"
+
+ Igniter.add_notice(igniter, """
+ ✅ Dashboard index page generated!
+
+ Page: #{live_view_module}
+ URL: #{url}
+
+ 📝 Add route BEFORE phoenix_kit_routes():
scope "/" do
pipe_through :browser
@@ -286,11 +334,10 @@ defmodule Mix.Tasks.PhoenixKit.Gen.User.Dashboard do
end
end
- 2. Force recompile: mix compile --force
-
- 3. Restart your server to see the new tab in the dashboard sidebar.
+ import PhoenixKitWeb.Integration
+ phoenix_kit_routes()
- The tab will appear under the "#{category}" category in the user dashboard.
+ Then: mix compile --force && restart server
""")
end
diff --git a/priv/templates/admin_category_index_page.ex b/priv/templates/admin_category_index_page.ex
index e102a231a..c48a4f919 100644
--- a/priv/templates/admin_category_index_page.ex
+++ b/priv/templates/admin_category_index_page.ex
@@ -1,4 +1,4 @@
-defmodule <%= @web_module_prefix %>.PhoenixKit.Live.Admin.<%= @category %>.Index do
+defmodule <%= @web_module_prefix %>.PhoenixKit.Admin.<%= @category %>.Index do
@moduledoc """
Index page for the <%= @category %> admin category.
"""
@@ -23,7 +23,7 @@ defmodule <%= @web_module_prefix %>.PhoenixKit.Live.Admin.<%= @category %>.Index
def render(assigns) do
~H"""
-
+
<.flash_messages flash={@flash} />
diff --git a/priv/templates/admin_category_page.ex b/priv/templates/admin_category_page.ex
index 3cecca4ef..322770805 100644
--- a/priv/templates/admin_category_page.ex
+++ b/priv/templates/admin_category_page.ex
@@ -1,4 +1,4 @@
-defmodule <%= @web_module_prefix %>.PhoenixKit.Live.Admin.<%= @category %>.<%= @page_name %> do
+defmodule <%= @web_module_prefix %>.PhoenixKit.Admin.<%= @category %>.<%= @page_name %> do
@moduledoc """
Admin LiveView for <%= @page_title %> in <%= @category %> category.
"""
@@ -23,7 +23,7 @@ defmodule <%= @web_module_prefix %>.PhoenixKit.Live.Admin.<%= @category %>.<%= @
def render(assigns) do
~H"""
-
+
<.flash_messages flash={@flash} />
diff --git a/priv/templates/user_dashboard_page.ex b/priv/templates/user_dashboard_page.ex
index 7088d73f8..9e7d3556b 100644
--- a/priv/templates/user_dashboard_page.ex
+++ b/priv/templates/user_dashboard_page.ex
@@ -6,6 +6,7 @@ defmodule <%= @web_module_prefix %>.PhoenixKit.Dashboard.<%= @page_name %> do
use <%= @web_module_prefix %>, :live_view
import PhoenixKitWeb.LayoutHelpers, only: [dashboard_assigns: 1]
+ import PhoenixKitWeb.Components.Core.UserDashboardHeader, only: [user_dashboard_header: 1]
@impl true
def mount(_params, _session, socket) do