diff --git a/lib/modules/emails/web/emails.html.heex b/lib/modules/emails/web/emails.html.heex index af349497..b8e49a00 100644 --- a/lib/modules/emails/web/emails.html.heex +++ b/lib/modules/emails/web/emails.html.heex @@ -220,7 +220,7 @@ <.link - navigate={Routes.path("/admin/modules/emails/templates")} + navigate={Routes.path("/admin/emails/templates")} class="btn btn-outline btn-secondary btn-sm" > <.icon name="hero-document-text" class="w-4 h-4" /> diff --git a/lib/modules/emails/web/template_editor.ex b/lib/modules/emails/web/template_editor.ex index f34d8b96..78fabd71 100644 --- a/lib/modules/emails/web/template_editor.ex +++ b/lib/modules/emails/web/template_editor.ex @@ -16,8 +16,8 @@ defmodule PhoenixKit.Modules.Emails.Web.TemplateEditor do ## Routes - - `/admin/modules/emails/templates/new` - Create new template - - `/admin/modules/emails/templates/:id/edit` - Edit existing template + - `/admin/emails/templates/new` - Create new template + - `/admin/emails/templates/:id/edit` - Edit existing template ## Permissions @@ -64,7 +64,7 @@ defmodule PhoenixKit.Modules.Emails.Web.TemplateEditor do {:noreply, socket |> put_flash(:error, "Template not found") - |> push_navigate(to: Routes.path("/admin/modules/emails/templates"))} + |> push_navigate(to: Routes.path("/admin/emails/templates"))} template -> changeset = Template.changeset(template, %{}) @@ -378,7 +378,7 @@ defmodule PhoenixKit.Modules.Emails.Web.TemplateEditor do socket |> assign(:saving, false) |> put_flash(:info, "Template '#{template.name}' created successfully") - |> push_navigate(to: Routes.path("/admin/modules/emails/templates"))} + |> push_navigate(to: Routes.path("/admin/emails/templates"))} {:error, changeset} -> {:noreply, diff --git a/lib/modules/emails/web/template_editor.html.heex b/lib/modules/emails/web/template_editor.html.heex index dee5430a..c76c4431 100644 --- a/lib/modules/emails/web/template_editor.html.heex +++ b/lib/modules/emails/web/template_editor.html.heex @@ -10,7 +10,7 @@
<%!-- Back Button (Left aligned) --%> <.link - navigate={Routes.path("/admin/modules/emails/templates")} + navigate={Routes.path("/admin/emails/templates")} class="btn btn-outline btn-primary btn-sm absolute left-0 top-0 -mb-12" > <.icon_arrow_left /> Back to Templates @@ -383,7 +383,7 @@
<.link - navigate={Routes.path("/admin/modules/emails/templates")} + navigate={Routes.path("/admin/emails/templates")} class="btn btn-ghost" > Cancel diff --git a/lib/modules/emails/web/templates.ex b/lib/modules/emails/web/templates.ex index 8732754f..3c9ace1d 100644 --- a/lib/modules/emails/web/templates.ex +++ b/lib/modules/emails/web/templates.ex @@ -17,7 +17,7 @@ defmodule PhoenixKit.Modules.Emails.Web.Templates do ## Route - This LiveView is mounted at `{prefix}/admin/modules/emails/templates` and requires + This LiveView is mounted at `{prefix}/admin/emails/templates` and requires appropriate admin permissions. Note: `{prefix}` is your configured PhoenixKit URL prefix (default: `/phoenix_kit`). @@ -110,14 +110,14 @@ defmodule PhoenixKit.Modules.Emails.Web.Templates do {:noreply, socket - |> push_patch(to: Routes.path("/admin/modules/emails/templates?#{new_params}"))} + |> push_patch(to: Routes.path("/admin/emails/templates?#{new_params}"))} end @impl true def handle_event("clear_filters", _params, socket) do {:noreply, socket - |> push_patch(to: Routes.path("/admin/modules/emails/templates"))} + |> push_patch(to: Routes.path("/admin/emails/templates"))} end @impl true @@ -188,9 +188,7 @@ defmodule PhoenixKit.Modules.Emails.Web.Templates do |> assign(:show_clone_modal, false) |> assign(:clone_template, nil) |> put_flash(:info, "Template cloned successfully as '#{new_template.name}'") - |> push_navigate( - to: Routes.path("/admin/modules/emails/templates/#{new_template.uuid}/edit") - )} + |> push_navigate(to: Routes.path("/admin/emails/templates/#{new_template.uuid}/edit"))} {:error, _changeset} -> {:noreply, @@ -213,7 +211,7 @@ defmodule PhoenixKit.Modules.Emails.Web.Templates do def handle_event("edit_template", %{"uuid" => template_uuid}, socket) do {:noreply, socket - |> push_navigate(to: Routes.path("/admin/modules/emails/templates/#{template_uuid}/edit"))} + |> push_navigate(to: Routes.path("/admin/emails/templates/#{template_uuid}/edit"))} end @impl true diff --git a/lib/modules/emails/web/templates.html.heex b/lib/modules/emails/web/templates.html.heex index 62d01347..aef0fbba 100644 --- a/lib/modules/emails/web/templates.html.heex +++ b/lib/modules/emails/web/templates.html.heex @@ -26,7 +26,7 @@ <%!-- Action Buttons --%>
<.link - navigate={Routes.path("/admin/modules/emails/templates/new")} + navigate={Routes.path("/admin/emails/templates/new")} class="btn btn-primary btn-sm" > <.icon name="hero-plus" class="w-4 h-4 mr-1" /> New Template @@ -316,7 +316,7 @@ <.pagination current_page={@page} total_pages={@total_pages} - base_path="/admin/modules/emails/templates" + base_path="/admin/emails/templates" params={ %{ "search" => @filters.search, diff --git a/lib/modules/entities/entity_data.ex b/lib/modules/entities/entity_data.ex index c1522fec..39d945c5 100644 --- a/lib/modules/entities/entity_data.ex +++ b/lib/modules/entities/entity_data.ex @@ -800,6 +800,83 @@ defmodule PhoenixKit.Modules.Entities.EntityData do """ def update_data(entity_data, attrs), do: __MODULE__.update(entity_data, attrs) + @doc """ + Bulk updates the status of multiple records by UUIDs. + + Returns a tuple with the count of updated records and nil. + + ## Examples + + iex> PhoenixKit.Modules.Entities.EntityData.bulk_update_status(["uuid1", "uuid2"], "archived") + {2, nil} + """ + def bulk_update_status(uuids, status) when is_list(uuids) and status in @valid_statuses do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + from(d in __MODULE__, where: d.uuid in ^uuids) + |> repo().update_all(set: [status: status, date_updated: now]) + end + + @doc """ + Bulk updates the category of multiple records by UUIDs. + + Since category is stored in the JSONB data column, this requires + fetching and updating each record individually. + + Returns a tuple with the count of updated records and nil. + + ## Examples + + iex> PhoenixKit.Modules.Entities.EntityData.bulk_update_category(["uuid1", "uuid2"], "New Category") + {2, nil} + """ + def bulk_update_category(uuids, category) when is_list(uuids) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + records = from(d in __MODULE__, where: d.uuid in ^uuids) |> repo().all() + + Enum.each(records, fn record -> + updated_data = Map.put(record.data || %{}, "category", category) + changeset = changeset(record, %{data: updated_data, date_updated: now}) + repo().update(changeset) + end) + + {length(records), nil} + end + + @doc """ + Bulk deletes multiple records by UUIDs. + + Returns a tuple with the count of deleted records and nil. + + ## Examples + + iex> PhoenixKit.Modules.Entities.EntityData.bulk_delete(["uuid1", "uuid2"]) + {2, nil} + """ + def bulk_delete(uuids) when is_list(uuids) do + from(d in __MODULE__, where: d.uuid in ^uuids) + |> repo().delete_all() + end + + @doc """ + Extracts unique categories from a list of entity data records. + + Returns a sorted list of unique category values, excluding nil and empty strings. + + ## Examples + + iex> PhoenixKit.Modules.Entities.EntityData.extract_unique_categories(records) + ["Category A", "Category B", "Category C"] + """ + def extract_unique_categories(entity_data_records) when is_list(entity_data_records) do + entity_data_records + |> Enum.map(fn r -> get_in(r.data, ["category"]) end) + |> Enum.reject(&(&1 == nil || &1 == "")) + |> Enum.uniq() + |> Enum.sort() + end + @doc """ Gets statistical data about entity data records. diff --git a/lib/modules/entities/web/data_navigator.ex b/lib/modules/entities/web/data_navigator.ex index 28716165..0e1e5f44 100644 --- a/lib/modules/entities/web/data_navigator.ex +++ b/lib/modules/entities/web/data_navigator.ex @@ -55,6 +55,9 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do |> assign(:selected_entity, entity) |> assign(:selected_entity_id, entity_id) |> assign(:selected_status, "all") + |> assign(:selected_category, "all") + |> assign(:selected_ids, []) + |> assign(:available_categories, []) |> assign(:search_term, "") |> assign(:view_mode, "table") |> apply_filters() @@ -75,6 +78,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do # Extract filter params with defaults status = params["status"] || "all" + category = params["category"] || "all" search_term = params["search"] || "" view_mode = params["view"] || "table" @@ -83,6 +87,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do |> assign(:selected_entity, entity) |> assign(:selected_entity_id, entity_id) |> assign(:selected_status, status) + |> assign(:selected_category, category) |> assign(:search_term, search_term) |> assign(:view_mode, view_mode) |> apply_filters() @@ -145,6 +150,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do build_url_params( socket.assigns.selected_entity_id, socket.assigns.selected_status, + socket.assigns.selected_category, socket.assigns.search_term, mode ) @@ -155,6 +161,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do socket = socket |> assign(:view_mode, mode) + |> assign(:selected_ids, []) |> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base)) {:noreply, socket} @@ -175,6 +182,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do build_url_params( entity_id, socket.assigns.selected_status, + socket.assigns.selected_category, socket.assigns.search_term, socket.assigns.view_mode ) @@ -183,7 +191,9 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do full_path = if params != "", do: "#{path}?#{params}", else: path socket = - push_patch(socket, to: Routes.path(full_path, locale: socket.assigns.current_locale_base)) + socket + |> assign(:selected_ids, []) + |> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base)) {:noreply, socket} end @@ -193,6 +203,28 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do build_url_params( socket.assigns.selected_entity_id, status, + socket.assigns.selected_category, + socket.assigns.search_term, + socket.assigns.view_mode + ) + + path = build_base_path(socket.assigns.selected_entity_id) + full_path = if params != "", do: "#{path}?#{params}", else: path + + socket = + socket + |> assign(:selected_ids, []) + |> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base)) + + {:noreply, socket} + end + + def handle_event("filter_by_category", %{"category" => category}, socket) do + params = + build_url_params( + socket.assigns.selected_entity_id, + socket.assigns.selected_status, + category, socket.assigns.search_term, socket.assigns.view_mode ) @@ -201,7 +233,9 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do full_path = if params != "", do: "#{path}?#{params}", else: path socket = - push_patch(socket, to: Routes.path(full_path, locale: socket.assigns.current_locale_base)) + socket + |> assign(:selected_ids, []) + |> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base)) {:noreply, socket} end @@ -211,6 +245,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do build_url_params( socket.assigns.selected_entity_id, socket.assigns.selected_status, + socket.assigns.selected_category, term, socket.assigns.view_mode ) @@ -219,20 +254,30 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do full_path = if params != "", do: "#{path}?#{params}", else: path socket = - push_patch(socket, to: Routes.path(full_path, locale: socket.assigns.current_locale_base)) + socket + |> assign(:selected_ids, []) + |> push_patch(to: Routes.path(full_path, locale: socket.assigns.current_locale_base)) {:noreply, socket} end def handle_event("clear_filters", _params, socket) do params = - build_url_params(socket.assigns.selected_entity_id, "all", "", socket.assigns.view_mode) + build_url_params( + socket.assigns.selected_entity_id, + "all", + "all", + "", + socket.assigns.view_mode + ) path = build_base_path(socket.assigns.selected_entity_id) full_path = if params != "", do: "#{path}?#{params}", else: path socket = - push_patch(socket, to: Routes.locale_aware_path(socket.assigns, full_path)) + socket + |> assign(:selected_ids, []) + |> push_patch(to: Routes.locale_aware_path(socket.assigns, full_path)) {:noreply, socket} end @@ -302,6 +347,110 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do end end + def handle_event("toggle_select", %{"uuid" => uuid}, socket) do + selected = socket.assigns.selected_ids + selected = if uuid in selected, do: List.delete(selected, uuid), else: [uuid | selected] + {:noreply, assign(socket, :selected_ids, selected)} + end + + def handle_event("select_all", _params, socket) do + all_uuids = Enum.map(socket.assigns.entity_data_records, & &1.uuid) + {:noreply, assign(socket, :selected_ids, all_uuids)} + end + + def handle_event("deselect_all", _params, socket) do + {:noreply, assign(socket, :selected_ids, [])} + end + + def handle_event("bulk_action", %{"action" => "archive"}, socket) do + ids = socket.assigns.selected_ids + + if ids == [] do + {:noreply, put_flash(socket, :error, gettext("No records selected"))} + else + {count, _} = EntityData.bulk_update_status(ids, "archived") + + {:noreply, + socket + |> assign(:selected_ids, []) + |> refresh_data_stats() + |> apply_filters() + |> put_flash(:info, gettext("%{count} records archived", count: count))} + end + end + + def handle_event("bulk_action", %{"action" => "restore"}, socket) do + ids = socket.assigns.selected_ids + + if ids == [] do + {:noreply, put_flash(socket, :error, gettext("No records selected"))} + else + {count, _} = EntityData.bulk_update_status(ids, "published") + + {:noreply, + socket + |> assign(:selected_ids, []) + |> refresh_data_stats() + |> apply_filters() + |> put_flash(:info, gettext("%{count} records restored", count: count))} + end + end + + def handle_event("bulk_action", %{"action" => "delete"}, socket) do + ids = socket.assigns.selected_ids + + if ids == [] do + {:noreply, put_flash(socket, :error, gettext("No records selected"))} + else + {count, _} = EntityData.bulk_delete(ids) + + {:noreply, + socket + |> assign(:selected_ids, []) + |> refresh_data_stats() + |> apply_filters() + |> put_flash(:info, gettext("%{count} records deleted", count: count))} + end + end + + def handle_event( + "bulk_action", + %{"action" => "change_category", "category" => category}, + socket + ) do + ids = socket.assigns.selected_ids + + if ids == [] do + {:noreply, put_flash(socket, :error, gettext("No records selected"))} + else + {count, _} = EntityData.bulk_update_category(ids, category) + + {:noreply, + socket + |> assign(:selected_ids, []) + |> refresh_data_stats() + |> apply_filters() + |> put_flash(:info, gettext("%{count} records updated", count: count))} + end + end + + def handle_event("bulk_action", %{"action" => "change_status", "status" => status}, socket) do + ids = socket.assigns.selected_ids + + if ids == [] do + {:noreply, put_flash(socket, :error, gettext("No records selected"))} + else + {count, _} = EntityData.bulk_update_status(ids, status) + + {:noreply, + socket + |> assign(:selected_ids, []) + |> refresh_data_stats() + |> apply_filters() + |> put_flash(:info, gettext("%{count} records updated", count: count))} + end + end + ## Live updates def handle_info({:entity_created, _entity_id}, socket) do @@ -376,7 +525,7 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do end end - defp build_url_params(_entity_id, status, search_term, view_mode) do + defp build_url_params(_entity_id, status, category, search_term, view_mode) do params = [] # Don't include entity_id in query params since it's in the path @@ -388,6 +537,13 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do params end + params = + if category && category != "all" do + [{"category", category} | params] + else + params + end + params = if search_term && String.trim(search_term) != "" do [{"search", search_term} | params] @@ -408,42 +564,69 @@ defmodule PhoenixKit.Modules.Entities.Web.DataNavigator do defp apply_filters(socket) do entity_id = socket.assigns[:selected_entity_id] status = socket.assigns[:selected_status] || "all" + category = socket.assigns[:selected_category] || "all" search_term = socket.assigns[:search_term] || "" - # Start with all data - entity_data_records = EntityData.list_all_data() - - # Apply entity filter + # Start with all data and apply filters sequentially entity_data_records = - if entity_id do - Enum.filter(entity_data_records, fn record -> record.entity_id == entity_id end) - else - entity_data_records - end + EntityData.list_all_data() + |> filter_by_entity(entity_id) + |> filter_by_status(status) + |> filter_by_category(category) + |> filter_by_search(search_term) - # Apply status filter - entity_data_records = - if status != "all" do - Enum.filter(entity_data_records, fn record -> record.status == status end) - else - entity_data_records - end + # Extract available categories from filtered results + available_categories = EntityData.extract_unique_categories(entity_data_records) - # Apply search filter - entity_data_records = - if String.trim(search_term) != "" do - search_term_lower = String.downcase(search_term) - - Enum.filter(entity_data_records, fn record -> - title_match = String.contains?(String.downcase(record.title || ""), search_term_lower) - slug_match = String.contains?(String.downcase(record.slug || ""), search_term_lower) - title_match || slug_match - end) - else - entity_data_records - end + socket + |> assign(:entity_data_records, entity_data_records) + |> assign(:available_categories, available_categories) + end - assign(socket, :entity_data_records, entity_data_records) + defp filter_by_entity(records, nil), do: records + + defp filter_by_entity(records, entity_id) do + Enum.filter(records, fn record -> record.entity_id == entity_id end) + end + + defp filter_by_status(records, "all"), do: records + + defp filter_by_status(records, status) do + Enum.filter(records, fn record -> record.status == status end) + end + + defp filter_by_category(records, "all"), do: records + + defp filter_by_category(records, "uncategorized") do + Enum.filter(records, fn record -> + cat = get_in(record.data, ["category"]) + is_nil(cat) || cat == "" + end) + end + + defp filter_by_category(records, category) do + Enum.filter(records, fn record -> + get_in(record.data, ["category"]) == category + end) + end + + defp filter_by_search(records, ""), do: records + + defp filter_by_search(records, search_term) do + search_term_lower = String.downcase(String.trim(search_term)) + + Enum.filter(records, fn record -> + title_match = String.contains?(String.downcase(record.title || ""), search_term_lower) + slug_match = String.contains?(String.downcase(record.slug || ""), search_term_lower) + + category_match = + case get_in(record.data, ["category"]) do + nil -> false + cat -> String.contains?(String.downcase(cat), search_term_lower) + end + + title_match || slug_match || category_match + end) end defp refresh_data_stats(socket) do diff --git a/lib/modules/entities/web/data_navigator.html.heex b/lib/modules/entities/web/data_navigator.html.heex index 538df01f..76f7acd3 100644 --- a/lib/modules/entities/web/data_navigator.html.heex +++ b/lib/modules/entities/web/data_navigator.html.heex @@ -188,17 +188,14 @@ <.icon name="hero-funnel" class="w-5 h-5" /> {gettext("Filters & Search")} -
+
<%!-- Status Filter --%>
<.form for={%{}} phx-change="filter_by_status"> - @@ -215,6 +212,28 @@
+ <%!-- Category Filter --%> +
+ + <.form for={%{}} phx-change="filter_by_category"> + + +
+ <%!-- Search --%>
+ + <%!-- Clear Filters --%> + <%= if @selected_status != "all" || @selected_category != "all" || @search_term != "" do %> +
+ +
+ <% end %>
+ <%!-- Bulk Actions Bar --%> + <%= if length(@selected_ids) > 0 do %> +
+
+
+ + {length(@selected_ids)} {gettext("selected")} + +
+ <%!-- Quick Actions --%> + + + + +
+ + <%!-- Change Status Dropdown --%> + + <%!-- Change Category Dropdown --%> + <%= if length(@available_categories) > 0 do %> + + <% end %> + +
+ +
+
+
+ <% end %> + <%!-- Results Section --%> <%= if Enum.empty?(@entity_data_records) do %> <%!-- Empty State --%> @@ -347,10 +490,29 @@ <.table_default variant="zebra" size="sm"> <.table_default_header> <.table_default_row> + <.table_default_header_cell class="w-12"> + <%= if length(@entity_data_records) > 0 do %> + 0 + } + phx-click={ + if length(@selected_ids) == length(@entity_data_records), + do: "deselect_all", + else: "select_all" + } + title={gettext("Select all")} + /> + <% end %> + <.table_default_header_cell>{gettext("Title")} <%= if !@selected_entity do %> <.table_default_header_cell>{gettext("Entity")} <% end %> + <.table_default_header_cell>{gettext("Category")} <.table_default_header_cell>{gettext("Status")} <.table_default_header_cell>{gettext("Created")} <.table_default_header_cell>{gettext("Actions")} @@ -359,6 +521,15 @@ <.table_default_body> <%= for data_record <- @entity_data_records do %> <.table_default_row> + <.table_default_cell> + + <.table_default_cell> <.link navigate={ @@ -384,6 +555,23 @@ <% end %> + <.table_default_cell> + <%= case get_in(data_record.data, ["category"]) do %> + <% nil -> %> + + {gettext("Uncategorized")} + + <% "" -> %> + + {gettext("Uncategorized")} + + <% category -> %> + + <.icon name="hero-tag" class="w-3 h-3 mr-1" /> + {category} + + <% end %> + <.table_default_cell> <.icon name={status_icon(data_record.status)} class="w-3 h-3 mr-1" /> @@ -455,82 +643,112 @@ <%= for data_record <- @entity_data_records do %>
-
- <.link - navigate={ - PhoenixKit.Utils.Routes.path( - "/admin/entities/#{get_entity_slug(@entities, data_record.entity_id)}/data/#{data_record.uuid}" - ) - } - class="flex-1 hover:text-primary transition-colors cursor-pointer" - > - <%!-- Title and Entity Info --%> -
-

{data_record.title}

- <%= if !@selected_entity do %> - - {get_entity_name(@entities, data_record.entity_id)} - - <% end %> -
+
+ +
+
+ <.link + navigate={ + PhoenixKit.Utils.Routes.path( + "/admin/entities/#{get_entity_slug(@entities, data_record.entity_id)}/data/#{data_record.uuid}" + ) + } + class="flex-1 hover:text-primary transition-colors cursor-pointer" + > + <%!-- Title and Entity Info --%> +
+

{data_record.title}

+ <%= if !@selected_entity do %> + + {get_entity_name(@entities, data_record.entity_id)} + + <% end %> +
- <%!-- Slug --%> - <%= if data_record.slug do %> -

- <.icon name="hero-link" class="w-4 h-4 inline mr-1" /> - {data_record.slug} -

- <% end %> + <%!-- Slug --%> + <%= if data_record.slug do %> +

+ <.icon name="hero-link" class="w-4 h-4 inline mr-1" /> + {data_record.slug} +

+ <% end %> - <%!-- Data Preview --%> - <%= if data_record.data && map_size(data_record.data) > 0 do %> -

- {format_data_preview(data_record.data)} -

- <% end %> - + <%!-- Category --%> + <%= case get_in(data_record.data, ["category"]) do %> + <% nil -> %> + + <.icon name="hero-tag" class="w-3 h-3 mr-1" /> + {gettext("Uncategorized")} + + <% "" -> %> + + <.icon name="hero-tag" class="w-3 h-3 mr-1" /> + {gettext("Uncategorized")} + + <% category -> %> + + <.icon name="hero-tag" class="w-3 h-3 mr-1" /> + {category} + + <% end %> - <%!-- Status Badge --%> -
- - <.icon name={status_icon(data_record.status)} class="w-3 h-3 mr-1" /> - {status_label(data_record.status)} - + <%!-- Data Preview --%> + <%= if data_record.data && map_size(data_record.data) > 0 do %> +

+ {format_data_preview(data_record.data)} +

+ <% end %> + - <%!-- Status Toggle Button --%> - -
-
+ <%!-- Status Badge --%> +
+ + <.icon name={status_icon(data_record.status)} class="w-3 h-3 mr-1" /> + {status_label(data_record.status)} + - <%!-- Metadata Row --%> -
- <%= if data_record.creator do %> - - <.icon name="hero-user" class="w-3 h-3 inline mr-1" /> - {data_record.creator.email} - - <% end %> - - <.icon name="hero-calendar" class="w-3 h-3 inline mr-1" /> - {gettext("Created")} {PhoenixKit.Utils.Date.format_date_with_user_format( - data_record.date_created - )} - - <%= if data_record.date_updated != data_record.date_created do %> - - <.icon name="hero-clock" class="w-3 h-3 inline mr-1" /> - {gettext("Updated")} {PhoenixKit.Utils.Date.format_date_with_user_format( - data_record.date_updated - )} - - <% end %> + <%!-- Status Toggle Button --%> + +
+
+ + <%!-- Metadata Row --%> +
+ <%= if data_record.creator do %> + + <.icon name="hero-user" class="w-3 h-3 inline mr-1" /> + {data_record.creator.email} + + <% end %> + + <.icon name="hero-calendar" class="w-3 h-3 inline mr-1" /> + {gettext("Created")} {PhoenixKit.Utils.Date.format_date_with_user_format( + data_record.date_created + )} + + <%= if data_record.date_updated != data_record.date_created do %> + + <.icon name="hero-clock" class="w-3 h-3 inline mr-1" /> + {gettext("Updated")} {PhoenixKit.Utils.Date.format_date_with_user_format( + data_record.date_updated + )} + + <% end %> +
+
<%!-- Actions --%> diff --git a/lib/modules/shop/events.ex b/lib/modules/shop/events.ex index 2f09d6b4..1c3de6d1 100644 --- a/lib/modules/shop/events.ex +++ b/lib/modules/shop/events.ex @@ -277,6 +277,27 @@ defmodule PhoenixKit.Modules.Shop.Events do broadcast(@categories_topic, {:category_deleted, category_id}) end + @doc """ + Broadcasts bulk category status changed event. + """ + def broadcast_categories_bulk_status_changed(category_ids, status) do + broadcast(@categories_topic, {:categories_bulk_status_changed, category_ids, status}) + end + + @doc """ + Broadcasts bulk category parent changed event. + """ + def broadcast_categories_bulk_parent_changed(category_ids, parent_uuid) do + broadcast(@categories_topic, {:categories_bulk_parent_changed, category_ids, parent_uuid}) + end + + @doc """ + Broadcasts bulk category deleted event. + """ + def broadcast_categories_bulk_deleted(category_ids) do + broadcast(@categories_topic, {:categories_bulk_deleted, category_ids}) + end + # ============================================ # INVENTORY BROADCAST FUNCTIONS # ============================================ diff --git a/lib/modules/shop/shop.ex b/lib/modules/shop/shop.ex index 9927aa65..f2b36a90 100644 --- a/lib/modules/shop/shop.ex +++ b/lib/modules/shop/shop.ex @@ -309,12 +309,18 @@ defmodule PhoenixKit.Modules.Shop do FROM phoenix_kit_shop_products p, jsonb_array_elements_text(COALESCE(p.metadata->'_option_values'->$1, '[]'::jsonb)) AS val WHERE p.status = 'active' - #{if category_uuid, do: "AND p.category_uuid = $2::uuid", else: ""} + #{if category_uuid, do: "AND p.category_uuid = $2", else: ""} GROUP BY val ORDER BY count DESC """ - params = if category_uuid, do: [key, category_uuid], else: [key] + params = + if category_uuid do + {:ok, uuid_bin} = Ecto.UUID.dump(category_uuid) + [key, uuid_bin] + else + [key] + end case repo().query(sql, params) do {:ok, %{rows: rows}} -> @@ -732,6 +738,20 @@ defmodule PhoenixKit.Modules.Shop do |> repo().all() end + @doc """ + Returns a map of category_id => product_count for all categories. + """ + def product_counts_by_category do + Product + |> where([p], not is_nil(p.category_id)) + |> group_by([p], p.category_id) + |> select([p], {p.category_id, count(p.id)}) + |> repo().all() + |> Map.new() + rescue + _ -> %{} + end + @doc """ Lists root categories (no parent). """ @@ -926,6 +946,112 @@ defmodule PhoenixKit.Modules.Shop do Category.changeset(category, attrs) end + @doc """ + Bulk update category status. + Returns count of updated categories. + """ + def bulk_update_category_status(ids, status) when is_list(ids) and is_binary(status) do + query = + if ids_are_uuids?(ids) do + Category |> where([c], c.uuid in ^ids) + else + Category |> where([c], c.id in ^ids) + end + + {count, _} = + query + |> repo().update_all(set: [status: status, updated_at: DateTime.utc_now()]) + + if count > 0 do + Events.broadcast_categories_bulk_status_changed(ids, status) + end + + count + end + + @doc """ + Bulk update category parent. + Returns count of updated categories. Excludes the target parent from the update set + to prevent self-reference. Uses a single UPDATE with subquery to resolve parent_id. + """ + def bulk_update_category_parent(ids, parent_uuid) when is_list(ids) do + # Exclude the target parent from update set to prevent self-reference + ids_to_update = if parent_uuid, do: Enum.reject(ids, &(&1 == parent_uuid)), else: ids + + if ids_to_update == [] do + 0 + else + now = DateTime.utc_now() + + {count, _} = + if is_nil(parent_uuid) do + # Make root — set both to nil + Category + |> where([c], c.uuid in ^ids_to_update) + |> repo().update_all(set: [parent_id: nil, parent_uuid: nil, updated_at: now]) + else + # Resolve parent_id in one query, then bulk update + parent = + Category + |> where([c], c.uuid == ^parent_uuid) + |> select([c], %{id: c.id}) + |> repo().one() + + if parent do + Category + |> where([c], c.uuid in ^ids_to_update) + |> repo().update_all( + set: [parent_id: parent.id, parent_uuid: parent_uuid, updated_at: now] + ) + else + {0, nil} + end + end + + if count > 0 do + Events.broadcast_categories_bulk_parent_changed(ids_to_update, parent_uuid) + end + + count + end + end + + @doc """ + Bulk delete categories. + Returns count of deleted categories. Nullifies category references on orphaned products. + """ + def bulk_delete_categories(ids) when is_list(ids) do + uuid_ids? = ids_are_uuids?(ids) + + # Nullify category references on products to prevent orphans + orphan_query = + if uuid_ids? do + Product |> where([p], p.category_uuid in ^ids) + else + Product |> where([p], p.category_id in ^ids) + end + + repo().update_all(orphan_query, + set: [category_id: nil, category_uuid: nil, updated_at: DateTime.utc_now()] + ) + + # Delete categories + category_query = + if uuid_ids? do + Category |> where([c], c.uuid in ^ids) + else + Category |> where([c], c.id in ^ids) + end + + {count, _} = repo().delete_all(category_query) + + if count > 0 do + Events.broadcast_categories_bulk_deleted(ids) + end + + count + end + @doc """ Returns categories as options for select input. Returns list of {localized_name, id} tuples. @@ -1011,7 +1137,6 @@ defmodule PhoenixKit.Modules.Shop do defp category_product_options_query(category_id) when is_integer(category_id) do from(p in Product, where: p.category_id == ^category_id, - where: p.status == "active", where: not is_nil(p.featured_image_id) or (not is_nil(p.featured_image) and p.featured_image != ""), @@ -1024,7 +1149,6 @@ defmodule PhoenixKit.Modules.Shop do if match?({:ok, _}, Ecto.UUID.cast(category_id)) do from(p in Product, where: p.category_uuid == ^category_id, - where: p.status == "active", where: not is_nil(p.featured_image_id) or (not is_nil(p.featured_image) and p.featured_image != ""), @@ -2378,7 +2502,8 @@ defmodule PhoenixKit.Modules.Shop do q, [p], fragment( - "EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(metadata->'_option_values'->?, '[]'::jsonb)) elem WHERE elem = ANY(?))", + "EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(?->'_option_values'->?, '[]'::jsonb)) elem WHERE elem = ANY(?))", + p.metadata, ^key, ^values ) @@ -2413,6 +2538,7 @@ defmodule PhoenixKit.Modules.Shop do defp apply_category_filters(query, opts) do query |> filter_by_parent(Keyword.get(opts, :parent_id, :skip)) + |> filter_by_parent_uuid(Keyword.get(opts, :parent_uuid, :skip)) |> filter_by_category_status(Keyword.get(opts, :status, :skip)) |> filter_by_category_search(Keyword.get(opts, :search)) end @@ -2421,7 +2547,12 @@ defmodule PhoenixKit.Modules.Shop do defp filter_by_parent(query, nil), do: where(query, [c], is_nil(c.parent_id)) defp filter_by_parent(query, id), do: where(query, [c], c.parent_id == ^id) + defp filter_by_parent_uuid(query, :skip), do: query + defp filter_by_parent_uuid(query, nil), do: where(query, [c], is_nil(c.parent_uuid)) + defp filter_by_parent_uuid(query, uuid), do: where(query, [c], c.parent_uuid == ^uuid) + defp filter_by_category_status(query, :skip), do: query + defp filter_by_category_status(query, nil), do: query defp filter_by_category_status(query, status) when is_binary(status) do where(query, [c], c.status == ^status) diff --git a/lib/modules/shop/web/catalog_category.ex b/lib/modules/shop/web/catalog_category.ex index f4243733..de21c428 100644 --- a/lib/modules/shop/web/catalog_category.ex +++ b/lib/modules/shop/web/catalog_category.ex @@ -104,6 +104,8 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogCategory do :category_icon_mode, Settings.get_setting_cached("shop_category_icon_mode", "none") ) + |> assign(:admin_edit_url, Routes.path("/admin/shop/categories/#{category.uuid}/edit")) + |> assign(:admin_edit_label, "Edit Category") {:ok, socket} end diff --git a/lib/modules/shop/web/catalog_product.ex b/lib/modules/shop/web/catalog_product.ex index 2bcb533f..149ca01f 100644 --- a/lib/modules/shop/web/catalog_product.ex +++ b/lib/modules/shop/web/catalog_product.ex @@ -133,6 +133,8 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogProduct do :category_icon_mode, Settings.get_setting_cached("shop_category_icon_mode", "none") ) + |> assign(:admin_edit_url, Routes.path("/admin/shop/products/#{product.uuid}/edit")) + |> assign(:admin_edit_label, "Edit Product") {:ok, socket} end @@ -262,6 +264,8 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogProduct do :category_icon_mode, Settings.get_setting_cached("shop_category_icon_mode", "none") ) + |> assign(:admin_edit_url, Routes.path("/admin/shop/products/#{product.id}")) + |> assign(:admin_edit_label, "Edit Product") {:ok, socket} end diff --git a/lib/modules/shop/web/categories.ex b/lib/modules/shop/web/categories.ex index 70c525a0..bb1fa811 100644 --- a/lib/modules/shop/web/categories.ex +++ b/lib/modules/shop/web/categories.ex @@ -1,6 +1,9 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do @moduledoc """ Categories list LiveView for Shop module. + + Provides search, filtering, pagination, and bulk operations + for category management. """ use PhoenixKitWeb, :live_view @@ -9,26 +12,100 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do alias PhoenixKit.Modules.Shop.Category alias PhoenixKit.Modules.Shop.Events alias PhoenixKit.Modules.Shop.Translations + alias PhoenixKit.Users.Auth.Scope alias PhoenixKit.Utils.Routes + @per_page 25 + @impl true def mount(_params, _session, socket) do if connected?(socket) do Events.subscribe_categories() end - categories = Shop.list_categories(preload: [:parent, :featured_product]) current_language = Translations.default_language() + all_categories = Shop.list_categories(preload: [:parent]) + + {categories, total} = + Shop.list_categories_with_count( + per_page: @per_page, + preload: [:parent, :featured_product] + ) + + product_counts = Shop.product_counts_by_category() socket = socket |> assign(:page_title, "Categories") |> assign(:categories, categories) + |> assign(:total, total) + |> assign(:page, 1) + |> assign(:per_page, @per_page) + |> assign(:search, "") + |> assign(:status_filter, nil) + |> assign(:parent_filter, nil) + |> assign(:all_categories, all_categories) |> assign(:current_language, current_language) + |> assign(:selected_ids, MapSet.new()) + |> assign(:show_bulk_modal, nil) + |> assign(:product_counts, product_counts) {:ok, socket} end + # ============================================ + # EVENT HANDLERS + # ============================================ + + @impl true + def handle_event("search", %{"search" => search}, socket) do + socket = + socket + |> assign(:search, search) + |> assign(:page, 1) + |> load_categories() + + {:noreply, socket} + end + + @impl true + def handle_event("filter_status", %{"status" => status}, socket) do + status = if status == "", do: nil, else: status + + socket = + socket + |> assign(:status_filter, status) + |> assign(:page, 1) + |> load_categories() + + {:noreply, socket} + end + + @impl true + def handle_event("filter_parent", %{"parent" => parent}, socket) do + parent = if parent == "", do: nil, else: parent + + socket = + socket + |> assign(:parent_filter, parent) + |> assign(:page, 1) + |> load_categories() + + {:noreply, socket} + end + + @impl true + def handle_event("change_page", %{"page" => page}, socket) do + page = String.to_integer(page) + + socket = + socket + |> assign(:page, page) + |> load_categories() + + {:noreply, socket} + end + @impl true def handle_event("delete", %{"uuid" => uuid}, socket) do category = Shop.get_category!(uuid) @@ -37,7 +114,7 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do {:ok, _} -> {:noreply, socket - |> reload_categories() + |> load_categories() |> put_flash(:info, "Category deleted")} {:error, _} -> @@ -45,29 +122,194 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do end end - # PubSub event handlers + @impl true + def handle_event("noop", _params, socket) do + {:noreply, socket} + end + + # Bulk selection events + + @impl true + def handle_event("toggle_select", %{"uuid" => uuid}, socket) do + selected = socket.assigns.selected_ids + + selected = + if MapSet.member?(selected, uuid) do + MapSet.delete(selected, uuid) + else + MapSet.put(selected, uuid) + end + + {:noreply, assign(socket, :selected_ids, selected)} + end + + @impl true + def handle_event("select_all", _params, socket) do + all_uuids = Enum.map(socket.assigns.categories, & &1.uuid) |> MapSet.new() + current = socket.assigns.selected_ids + + selected = + if MapSet.subset?(all_uuids, current) do + MapSet.difference(current, all_uuids) + else + MapSet.union(current, all_uuids) + end + + {:noreply, assign(socket, :selected_ids, selected)} + end + + @impl true + def handle_event("clear_selection", _params, socket) do + {:noreply, assign(socket, :selected_ids, MapSet.new())} + end + + # Bulk action modals + + @impl true + def handle_event("show_bulk_modal", %{"action" => action}, socket) do + {:noreply, assign(socket, :show_bulk_modal, action)} + end + + @impl true + def handle_event("close_bulk_modal", _params, socket) do + {:noreply, assign(socket, :show_bulk_modal, nil)} + end + + # Bulk actions (require admin role) + + @impl true + def handle_event("bulk_change_status", %{"status" => status}, socket) do + if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do + ids = MapSet.to_list(socket.assigns.selected_ids) + count = Shop.bulk_update_category_status(ids, status) + + {:noreply, + socket + |> load_categories() + |> assign(:selected_ids, MapSet.new()) + |> assign(:show_bulk_modal, nil) + |> put_flash(:info, "#{count} categories updated to #{status}")} + else + {:noreply, put_flash(socket, :error, "Not authorized")} + end + end + + @impl true + def handle_event("bulk_change_parent", %{"parent_uuid" => parent_uuid}, socket) do + if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do + ids = MapSet.to_list(socket.assigns.selected_ids) + parent_uuid = if parent_uuid == "", do: nil, else: parent_uuid + count = Shop.bulk_update_category_parent(ids, parent_uuid) + + {:noreply, + socket + |> load_categories() + |> assign(:selected_ids, MapSet.new()) + |> assign(:show_bulk_modal, nil) + |> put_flash(:info, "#{count} categories updated")} + else + {:noreply, put_flash(socket, :error, "Not authorized")} + end + end + + @impl true + def handle_event("bulk_delete", _params, socket) do + if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do + ids = MapSet.to_list(socket.assigns.selected_ids) + count = Shop.bulk_delete_categories(ids) + + {:noreply, + socket + |> load_categories() + |> assign(:selected_ids, MapSet.new()) + |> assign(:show_bulk_modal, nil) + |> put_flash(:info, "#{count} categories deleted")} + else + {:noreply, put_flash(socket, :error, "Not authorized")} + end + end + + # ============================================ + # PUBSUB HANDLERS + # ============================================ + @impl true def handle_info({:category_created, _category}, socket) do - {:noreply, reload_categories(socket)} + {:noreply, load_categories(socket)} end @impl true def handle_info({:category_updated, _category}, socket) do - {:noreply, reload_categories(socket)} + {:noreply, load_categories(socket)} end @impl true def handle_info({:category_deleted, _category_id}, socket) do - {:noreply, reload_categories(socket)} + {:noreply, load_categories(socket)} end - defp reload_categories(socket) do - categories = Shop.list_categories(preload: [:parent, :featured_product]) + @impl true + def handle_info({:categories_bulk_status_changed, _ids, _status}, socket) do + {:noreply, load_categories(socket)} + end + + @impl true + def handle_info({:categories_bulk_parent_changed, _ids, _parent_uuid}, socket) do + {:noreply, load_categories(socket)} + end + + @impl true + def handle_info({:categories_bulk_deleted, _ids}, socket) do + {:noreply, load_categories(socket)} + end + + # ============================================ + # PRIVATE HELPERS + # ============================================ + + defp load_categories(socket) do + parent_uuid_opt = + case socket.assigns.parent_filter do + nil -> :skip + "root" -> nil + uuid -> uuid + end + + opts = [ + page: socket.assigns.page, + per_page: @per_page, + search: socket.assigns.search, + status: socket.assigns.status_filter, + parent_uuid: parent_uuid_opt, + preload: [:parent, :featured_product] + ] + + {categories, total} = Shop.list_categories_with_count(opts) + all_categories = Shop.list_categories(preload: [:parent]) + + product_counts = Shop.product_counts_by_category() socket |> assign(:categories, categories) + |> assign(:total, total) + |> assign(:all_categories, all_categories) + |> assign(:product_counts, product_counts) + end + + defp all_selected?(categories, selected_ids) do + categories != [] and + Enum.all?(categories, fn c -> MapSet.member?(selected_ids, c.uuid) end) end + defp status_badge_class("active"), do: "badge badge-success" + defp status_badge_class("unlisted"), do: "badge badge-warning" + defp status_badge_class("hidden"), do: "badge badge-error" + defp status_badge_class(_), do: "badge badge-success" + + # ============================================ + # RENDER + # ============================================ + @impl true def render(assigns) do ~H""" @@ -78,7 +320,7 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do current_locale={@current_locale} page_title={@page_title} > -
+
<%!-- Header --%>
@@ -90,43 +332,143 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do

Categories

-

{length(@categories)} categories

+

+ {if @total == 1, do: "1 category", else: "#{@total} categories"} +

<%!-- Controls Bar --%>
-
- <%!-- Actions --%> -
- <.link navigate={Routes.path("/admin/shop/categories/new")} class="btn btn-primary"> +
+ <%!-- Search --%> +
+ +
+ +
+
+ + <%!-- Status Filter --%> +
+ +
+ +
+
+ + <%!-- Parent Filter --%> +
+ +
+ +
+
+ + <%!-- Add Button --%> +
+ + <.link + navigate={Routes.path("/admin/shop/categories/new")} + class="btn btn-primary w-full" + > <.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Category
+ <%!-- Bulk Actions Bar --%> + <%= if MapSet.size(@selected_ids) > 0 do %> +
+
+
+ + {MapSet.size(@selected_ids)} selected + + +
+
+ + + +
+
+
+ <% end %> + <%!-- Categories Table --%>
+ + <%= if Enum.empty?(@categories) do %> - @@ -134,7 +476,24 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do <%= for category <- @categories do %> <% cat_name = Translations.get(category, :name, @current_language) %> <% cat_slug = Translations.get(category, :slug, @current_language) %> - + + - +
+ + Name Slug Parent Status PositionProducts Actions
+ <.icon name="hero-folder" class="w-12 h-12 mx-auto mb-3 opacity-50" /> -

No categories yet

+

No categories found

Create your first category to organize products

+ +
@@ -156,7 +515,7 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do {Translations.get(category.parent, :name, @current_language)} <% else %> - + <% end %>
@@ -165,7 +524,12 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do {category.position} + + + {Map.get(@product_counts, category.id, 0)} + +
<.link navigate={Routes.path("/admin/shop/categories/#{category.uuid}/edit")} @@ -189,14 +553,129 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do
+ + <%!-- Pagination --%> + <%= if @total > @per_page do %> +
+
+
+ <%= for page <- 1..ceil(@total / @per_page) do %> + + <% end %> +
+
+
+ <% end %>
+ + <%!-- Bulk Status Change Modal --%> + <%= if @show_bulk_modal == "status" do %> + + <% end %> + + <%!-- Bulk Parent Change Modal --%> + <%= if @show_bulk_modal == "parent" do %> + + <% end %> + + <%!-- Bulk Delete Confirmation Modal --%> + <%= if @show_bulk_modal == "delete" do %> + + <% end %> """ end - - defp status_badge_class("active"), do: "badge badge-success" - defp status_badge_class("unlisted"), do: "badge badge-warning" - defp status_badge_class("hidden"), do: "badge badge-error" - defp status_badge_class(_), do: "badge badge-success" end diff --git a/lib/modules/shop/web/category_form.ex b/lib/modules/shop/web/category_form.ex index 2dab0657..299e900b 100644 --- a/lib/modules/shop/web/category_form.ex +++ b/lib/modules/shop/web/category_form.ex @@ -570,7 +570,7 @@ defmodule PhoenixKit.Modules.Shop.Web.CategoryForm do
<%!-- Featured Product (fallback image source) --%> - <%= if @live_action == :edit and @product_options != [] do %> + <%= if @live_action == :edit do %>
- + <%= if @product_options != [] do %> + + <% else %> +
+ <.icon name="hero-information-circle" class="w-4 h-4 inline mr-1" /> + No products with images in this category. Add product images to enable this option. +
+ <% end %>
diff --git a/lib/phoenix_kit_web/components/user_dashboard_nav.ex b/lib/phoenix_kit_web/components/user_dashboard_nav.ex index 9a9b5122..f4bf6d8e 100644 --- a/lib/phoenix_kit_web/components/user_dashboard_nav.ex +++ b/lib/phoenix_kit_web/components/user_dashboard_nav.ex @@ -18,6 +18,8 @@ defmodule PhoenixKitWeb.Components.UserDashboardNav do attr(:scope, :any, default: nil) attr(:current_path, :string, default: "") attr(:current_locale, :string, default: "en") + attr(:admin_edit_url, :string, default: nil) + attr(:admin_edit_label, :string, default: nil) def user_dropdown(assigns) do user = Scope.user(assigns.scope) @@ -65,6 +67,17 @@ defmodule PhoenixKitWeb.Components.UserDashboardNav do Admin Panel + <%= if @admin_edit_url do %> +
  • + + <.icon name="hero-pencil-square" class="w-4 h-4" /> + {@admin_edit_label || "Edit"} + +
  • + <% end %> <% end %>
  • diff --git a/lib/phoenix_kit_web/live/modules.html.heex b/lib/phoenix_kit_web/live/modules.html.heex index ba69043e..cae6e355 100644 --- a/lib/phoenix_kit_web/live/modules.html.heex +++ b/lib/phoenix_kit_web/live/modules.html.heex @@ -166,7 +166,7 @@ <.icon name="hero-envelope" class="w-4 h-4 mr-1" /> Emails <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/modules/emails/templates")} + navigate={PhoenixKit.Utils.Routes.path("/admin/emails/templates")} class="btn btn-outline btn-sm flex-1" > <.icon name="hero-document-text" class="w-4 h-4 mr-1" /> Templates diff --git a/lib/phoenix_kit_web/routes/emails.ex b/lib/phoenix_kit_web/routes/emails.ex index aca1c807..99f4621a 100644 --- a/lib/phoenix_kit_web/routes/emails.ex +++ b/lib/phoenix_kit_web/routes/emails.ex @@ -60,15 +60,15 @@ defmodule PhoenixKitWeb.Routes.EmailsRoutes do live "/admin/emails/blocklist", PhoenixKit.Modules.Emails.Web.Blocklist, :index, as: :emails_blocklist - live "/admin/modules/emails/templates", PhoenixKit.Modules.Emails.Web.Templates, :index, + live "/admin/emails/templates", PhoenixKit.Modules.Emails.Web.Templates, :index, as: :emails_templates - live "/admin/modules/emails/templates/new", + live "/admin/emails/templates/new", PhoenixKit.Modules.Emails.Web.TemplateEditor, :new, as: :emails_template_new - live "/admin/modules/emails/templates/:id/edit", + live "/admin/emails/templates/:id/edit", PhoenixKit.Modules.Emails.Web.TemplateEditor, :edit, as: :emails_template_edit diff --git a/priv/static/images/mim/cards/instrument-shafts.png b/priv/static/images/mim/cards/instrument-shafts.png new file mode 100644 index 00000000..fe64ea3d Binary files /dev/null and b/priv/static/images/mim/cards/instrument-shafts.png differ diff --git a/priv/static/images/mim/cards/instrument-tips.png b/priv/static/images/mim/cards/instrument-tips.png new file mode 100644 index 00000000..537dea90 Binary files /dev/null and b/priv/static/images/mim/cards/instrument-tips.png differ diff --git a/priv/static/images/mim/cards/small-components.png b/priv/static/images/mim/cards/small-components.png new file mode 100644 index 00000000..f753611a Binary files /dev/null and b/priv/static/images/mim/cards/small-components.png differ diff --git a/priv/static/images/mim/cards/surgical-clamp.png b/priv/static/images/mim/cards/surgical-clamp.png new file mode 100644 index 00000000..84f45644 Binary files /dev/null and b/priv/static/images/mim/cards/surgical-clamp.png differ diff --git a/priv/static/images/mim/cards/surgical-handles.png b/priv/static/images/mim/cards/surgical-handles.png new file mode 100644 index 00000000..57e715ad Binary files /dev/null and b/priv/static/images/mim/cards/surgical-handles.png differ diff --git a/priv/static/images/mim/instrument-shafts-nobg.png b/priv/static/images/mim/instrument-shafts-nobg.png new file mode 100644 index 00000000..acfc03ae Binary files /dev/null and b/priv/static/images/mim/instrument-shafts-nobg.png differ diff --git a/priv/static/images/mim/instrument-tips-nobg.png b/priv/static/images/mim/instrument-tips-nobg.png new file mode 100644 index 00000000..fffc24ec Binary files /dev/null and b/priv/static/images/mim/instrument-tips-nobg.png differ diff --git a/priv/static/images/mim/mim-all-instruments.png b/priv/static/images/mim/mim-all-instruments.png new file mode 100644 index 00000000..b9187732 Binary files /dev/null and b/priv/static/images/mim/mim-all-instruments.png differ diff --git a/priv/static/images/mim/small-components-nobg.png b/priv/static/images/mim/small-components-nobg.png new file mode 100644 index 00000000..1be1ae97 Binary files /dev/null and b/priv/static/images/mim/small-components-nobg.png differ diff --git a/priv/static/images/mim/surgical-clamp-nobg.png b/priv/static/images/mim/surgical-clamp-nobg.png new file mode 100644 index 00000000..af85bc9f Binary files /dev/null and b/priv/static/images/mim/surgical-clamp-nobg.png differ diff --git a/priv/static/images/mim/surgical-handles-nobg.png b/priv/static/images/mim/surgical-handles-nobg.png new file mode 100644 index 00000000..66bc295d Binary files /dev/null and b/priv/static/images/mim/surgical-handles-nobg.png differ