diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ab1f5797..a9b1bd6d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,13 @@ "Bash(mix format)", "Bash(mix credo:*)", "Bash(mix do:*)", - "Bash(mix gettext.extract:*)" + "Bash(mix gettext.extract:*)", + "Bash(mv:*)", + "Bash(mix compile)", + "Bash(mix phx.routes:*)", + "Bash(mix assets.deploy:*)", + "mcp__tidewave__get_logs", + "mcp__tidewave__project_eval" ] }, "enableAllProjectMcpServers": false diff --git a/assets/js/app.js b/assets/js/app.js index e2466f85..93798dd9 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -23,11 +23,14 @@ import { LiveSocket } from "phoenix_live_view"; import topbar from "../vendor/topbar"; import { Hooks as FluxonHooks, DOM as FluxonDOM } from "fluxon"; import BarcodeScannerHook from "./barcode-scanner"; +import { SearchHook, SearchInputHook } from "./search"; import confetti from "canvas-confetti"; import { createLiveToastHook } from "live_toast"; let Hooks = FluxonHooks; Hooks.BarcodeScanner = BarcodeScannerHook; +Hooks.Search = SearchHook; +Hooks.SearchInput = SearchInputHook; Hooks.LiveToast = createLiveToastHook(); let csrfToken = document diff --git a/assets/js/search.js b/assets/js/search.js new file mode 100644 index 00000000..bbc86fd1 --- /dev/null +++ b/assets/js/search.js @@ -0,0 +1,56 @@ +// Search hook for universal search functionality +const SearchHook = { + mounted() { + this.setupGlobalKeyboardShortcuts(); + this.setupSearchInputFocus(); + }, + + updated() { + this.setupSearchInputFocus(); + }, + + setupGlobalKeyboardShortcuts() { + // Add global keyboard event listener for Ctrl/Cmd + K + document.addEventListener('keydown', (e) => { + // Check for Ctrl+K (Windows/Linux) or Cmd+K (Mac) + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + // Send event to the universal search LiveView + const searchContainer = document.getElementById('universal-search'); + if (searchContainer) { + searchContainer.dispatchEvent(new CustomEvent('phx:open_modal', { + bubbles: true, + detail: {} + })); + } + } + }); + }, + + setupSearchInputFocus() { + // Auto-focus search input when modal opens + const searchInput = document.getElementById('universal-search-input'); + if (searchInput) { + // Small delay to ensure modal is fully rendered + setTimeout(() => { + searchInput.focus(); + }, 100); + } + } +}; + +// Hook for search input with auto-focus +const SearchInputHook = { + mounted() { + this.el.focus(); + }, + + updated() { + // Maintain focus during updates + if (document.activeElement !== this.el) { + this.el.focus(); + } + } +}; + +export { SearchHook, SearchInputHook }; \ No newline at end of file diff --git a/docs/plans/universal_search.md b/docs/plans/universal_search.md new file mode 100644 index 00000000..9ed81a43 --- /dev/null +++ b/docs/plans/universal_search.md @@ -0,0 +1,345 @@ +# Universal Search Modal Implementation Plan + +## Overview + +This plan outlines the implementation of a universal search modal that provides unified search across Records (Collection), Records (Wishlist), and Artists. The modal will be accessible via a search icon in the top navigation bar and a keyboard shortcut. + +## Requirements + +### Core Features +- Modal accessible via: + - Search icon in top navigation bar + - Keyboard shortcut (Ctrl/Cmd + K) +- Search across three entity types: + 1. Records in Collection + 2. Records in Wishlist + 3. Artists +- Real-time search as user types +- Keyboard navigation support +- Mobile-responsive design + +### Search Functionality +- Leverage existing FTS5 search infrastructure +- Support existing search syntax (artist:, album:, genre:, etc.) +- Show search results grouped by entity type +- Limit results per category (e.g., 5 per type) +- Click-to-navigate to full results or individual records + +## Technical Architecture + +### 1. Modal Component Structure + +**New Components:** +- `UniversalSearchModal` - Main modal component +- `SearchResultGroup` - Groups results by type (Collection, Wishlist, Artists) +- `SearchResultItem` - Individual result display +- `SearchInput` - Enhanced search input with keyboard shortcuts + +**Component Location:** +``` +lib/music_library_web/components/ +├── search_components.ex # New universal search components +└── layouts/app.html.heex # Add search icon to navigation +``` + +### 2. LiveView Integration + +**New LiveView Module:** +``` +lib/music_library_web/live/universal_search_live/ +├── index.ex # Main search LiveView +└── index.html.heex # Search modal template +``` + +**Integration Points:** +- Mount as child LiveView in main layout +- Handle modal open/close state +- Manage search query and results +- Handle keyboard navigation + +### 3. Backend Search Implementation + +**New Context Functions:** +```elixir +# In lib/music_library/search.ex (new module) +def universal_search(query, opts \\ []) +def search_collection(query, limit \\ 5) +def search_wishlist(query, limit \\ 5) +def search_artists(query, limit \\ 5) +``` + +**Search Strategy:** +- Reuse existing `Records.search_collection/2` and `Records.search_wishlist/2` +- Extend `Artists.search/2` or create new artist search function +- Combine results with type metadata +- Implement result limiting and pagination + +### 4. Database Considerations + +**Leverage Existing Infrastructure:** +- Use existing `records_search_index` FTS5 table +- Utilize existing search parser for tagged queries +- Consider artist search optimization (may need artist search index) + +**Artist Search Enhancement:** +- Current artist search may need optimization +- Consider adding FTS5 index for artist_infos table if performance issues +- Leverage existing artist_records view for artist-record relationships + +## Implementation Details + +### 1. Modal Behavior + +**Desktop:** +- Modal opens centered on screen +- Size: `max-w-2xl` (responsive) +- Overlay with backdrop blur +- Esc key to close, click outside to close + +**Mobile:** +- Full-screen modal on mobile (`sm:max-w-2xl max-w-full`) +- Touch-friendly result items +- Swipe down to close (if feasible) +- Virtual keyboard considerations + +### 2. Search Interface + +**Search Input:** +- Placeholder: "Search records and artists..." +- Auto-focus when modal opens +- Debounced search (300ms delay) +- Clear button when text present +- Loading indicator during search + +**Search Results:** +``` +┌─────────────────────────────────────┐ +│ Search: "radiohead ok computer" │ +├─────────────────────────────────────┤ +│ COLLECTION (3 results) │ +│ • OK Computer - Radiohead │ +│ • Kid A - Radiohead │ +│ • In Rainbows - Radiohead │ +│ View all 12 collection results → │ +├─────────────────────────────────────┤ +│ WISHLIST (1 result) │ +│ • Hail to the Thief - Radiohead │ +│ View all 1 wishlist results → │ +├─────────────────────────────────────┤ +│ ARTISTS (1 result) │ +│ • Radiohead │ +│ View artist page → │ +└─────────────────────────────────────┘ +``` + +### 3. Keyboard Navigation + +**Keyboard Shortcuts:** +- `Ctrl/Cmd + K` - Open modal +- `Esc` - Close modal +- `↑/↓` - Navigate results +- `Enter` - Select highlighted result +- `Tab` - Move between sections + +**Implementation:** +- Use Phoenix LiveView's `phx-window-keydown` for global shortcuts +- Implement focus management with JavaScript hooks +- Maintain accessibility standards (ARIA labels, focus indicators) + +### 4. Result Navigation + +**Click Actions:** +- Record results → Navigate to record show page +- Artist results → Navigate to artist page +- "View all X results" → Navigate to respective list page with search applied + +**URL Strategy:** +- Don't change URL for modal open/close +- When navigating to results, apply search query to destination page +- Use existing search parameter patterns + +## Database Schema Impact + +**No Schema Changes Required:** +- Leverage existing tables and search infrastructure +- May add database indexes for performance if needed + +**Performance Considerations:** +- Monitor query performance with combined searches +- Consider query result caching for common searches +- Implement search result limits to prevent slow queries + +## Testing Strategy + +### 1. Unit Tests + +**Search Logic Tests:** +```elixir +# test/music_library/search_test.exs +describe "universal_search/2" do + test "searches across all entity types" + test "limits results per category" + test "handles empty queries" + test "handles special characters" + test "respects search syntax (artist:, album:, etc.)" +end +``` + +### 2. Integration Tests + +**LiveView Tests:** +```elixir +# test/music_library_web/live/universal_search_live_test.exs +describe "UniversalSearchLive" do + test "opens modal on keyboard shortcut" + test "searches as user types" + test "displays results grouped by type" + test "navigates to selected results" + test "handles keyboard navigation" + test "closes modal on escape" +end +``` + +### 3. Component Tests + +**Search Components:** +```elixir +# test/music_library_web/components/search_components_test.exs +describe "SearchResultGroup" do + test "renders results with proper grouping" + test "shows 'view all' link when more results available" + test "handles empty result sets" +end +``` + +### 4. JavaScript/Hook Tests + +**Browser Tests:** +- Test keyboard shortcuts work globally +- Test focus management +- Test modal open/close behavior +- Test mobile touch interactions + +## Mobile Considerations + +### 1. Layout Adaptations + +**Mobile Modal:** +- Full-screen on small devices +- Reduced padding and margins +- Larger touch targets for results +- Simplified result display (fewer details) + +**Touch Interactions:** +- Swipe down to close modal +- Tap to select results +- Pull-to-refresh for search results + +### 2. Performance + +**Mobile Optimization:** +- Shorter debounce delays on mobile +- Reduced result limits on mobile +- Lazy loading for large result sets +- Optimize image loading for album covers + +### 3. Virtual Keyboard + +**iOS/Android Considerations:** +- Modal positioning with virtual keyboard +- Proper input focus management +- Prevent zoom on input focus + +## Implementation Phases + +### Phase 1: Core Modal Infrastructure +1. Create basic modal component +2. Add search icon to navigation +3. Implement modal open/close +4. Add keyboard shortcut support + +### Phase 2: Search Backend +1. Create universal search context +2. Implement combined search functions +3. Add result limiting and grouping +4. Optimize query performance + +### Phase 3: Search Interface +1. Implement search input with debouncing +2. Add search result display +3. Implement keyboard navigation +4. Add result selection and navigation + +### Phase 4: Mobile & Polish +1. Mobile layout optimizations +2. Touch interaction improvements +3. Performance optimizations +4. Accessibility enhancements + +### Phase 5: Testing & Documentation +1. Comprehensive test coverage +2. Performance testing +3. Mobile device testing +4. Documentation updates + +## Success Metrics + +**Functionality:** +- Modal opens/closes reliably +- Search results are accurate and fast +- Keyboard navigation works smoothly +- Mobile experience is touch-friendly + +**Performance:** +- Search queries complete in <500ms +- Modal opens in <100ms +- No janky animations or interactions +- Efficient database queries + +**Usability:** +- Easy to discover and use +- Intuitive keyboard shortcuts +- Clear result presentation +- Seamless navigation to results + +## Risks and Mitigation + +**Performance Risks:** +- Complex queries may be slow +- **Mitigation:** Implement query limits, caching, and monitoring + +**Mobile Experience:** +- Small screen constraints +- **Mitigation:** Responsive design, touch-friendly interfaces + +**Accessibility:** +- Keyboard navigation complexity +- **Mitigation:** Follow ARIA guidelines, comprehensive testing + +**Search Accuracy:** +- Users may expect Google-like search +- **Mitigation:** Clear documentation of search syntax, good defaults + +## Future Enhancements + +**Advanced Features:** +- Search history and suggestions +- Saved searches +- Search filters and facets +- Search result previews + +**Integration:** +- Integration with barcode scanning +- Voice search capabilities +- External search (MusicBrainz, Last.fm) + +**Analytics:** +- Search usage analytics +- Popular search terms +- Search success rates + +## Conclusion + +This universal search modal will significantly improve the user experience by providing quick access to all content types from any page. The implementation leverages existing infrastructure while adding a modern, accessible search interface that works well on both desktop and mobile devices. + +The phased approach ensures a solid foundation while allowing for iterative improvements based on user feedback and usage patterns. \ No newline at end of file diff --git a/lib/music_library/search.ex b/lib/music_library/search.ex new file mode 100644 index 00000000..f377c499 --- /dev/null +++ b/lib/music_library/search.ex @@ -0,0 +1,118 @@ +defmodule MusicLibrary.Search do + @moduledoc """ + Universal search functionality across Records and Artists. + + This module provides unified search across: + - Records in Collection (purchased records) + - Records in Wishlist (unpurchased records) + - Artists + """ + + import Ecto.Query, warn: false + + alias MusicLibrary.Records.ArtistRecord + alias MusicLibrary.{Collection, Repo, Wishlist} + + @doc """ + Performs a universal search across all entity types. + + Returns a map with grouped results: + - :collection - Records in the collection + - :wishlist - Records in the wishlist + - :artists - Artists + + Options: + - :limit - Limit per category (default: 5) + """ + def universal_search(query, opts \\ []) do + limit = Keyword.get(opts, :limit, 5) + + %{ + collection: search_collection(query, limit), + wishlist: search_wishlist(query, limit), + artists: search_artists(query, limit) + } + end + + @doc """ + Searches records in the collection (purchased records). + """ + def search_collection(query, limit \\ 5) do + Collection.search_records(query, limit: limit) + end + + @doc """ + Searches records in the wishlist (unpurchased records). + """ + def search_wishlist(query, limit \\ 5) do + Wishlist.search_records(query, limit: limit) + end + + @doc """ + Searches artists by name. + + Searches across artist names in the artist_records table, + returning distinct artists that match the query. + """ + def search_artists(query, limit \\ 5) do + case String.trim(query) do + "" -> + [] + + trimmed_query -> + normalized_query = String.downcase(trimmed_query) + + q = + from ar in ArtistRecord, + where: fragment("lower(artist ->> '$.name') LIKE ?", ^"%#{normalized_query}%"), + group_by: ar.musicbrainz_id, + select: ar.artist, + limit: ^limit, + order_by: fragment("artist ->> '$.name'") + + Repo.all(q) + end + end + + @doc """ + Gets the count of search results for each category. + + Returns a map with counts for each category: + - :collection_count + - :wishlist_count + - :artists_count + """ + def search_counts(query) do + %{ + collection_count: Collection.search_records_count(query), + wishlist_count: Wishlist.search_records_count(query), + artists_count: search_artists_count(query) + } + end + + @doc """ + Gets the count of artists matching the search query. + """ + def search_artists_count(query) do + case String.trim(query) do + "" -> + 0 + + trimmed_query -> + normalized_query = String.downcase(trimmed_query) + + # Use a subquery to count distinct musicbrainz_ids + subquery = + from ar in ArtistRecord, + where: fragment("lower(artist ->> '$.name') LIKE ?", ^"%#{normalized_query}%"), + select: ar.musicbrainz_id, + distinct: true + + q = + from s in subquery(subquery), + select: count() + + Repo.one(q) || 0 + end + end +end diff --git a/lib/music_library_web/components/layouts/app.html.heex b/lib/music_library_web/components/layouts/app.html.heex index 2f89a91a..1d817eee 100644 --- a/lib/music_library_web/components/layouts/app.html.heex +++ b/lib/music_library_web/components/layouts/app.html.heex @@ -38,6 +38,10 @@
+ <.dropdown placement="bottom-end"> <:toggle>
+ +
+ {live_render(@socket, MusicLibraryWeb.UniversalSearchLive.Index, + id: "universal-search", + session: %{} + )} +
diff --git a/lib/music_library_web/components/search_components.ex b/lib/music_library_web/components/search_components.ex new file mode 100644 index 00000000..7cf3c09c --- /dev/null +++ b/lib/music_library_web/components/search_components.ex @@ -0,0 +1,275 @@ +defmodule MusicLibraryWeb.SearchComponents do + @moduledoc """ + Universal search modal and related components. + """ + use Phoenix.Component + use Gettext, backend: MusicLibraryWeb.Gettext + + import MusicLibraryWeb.CoreComponents, only: [icon: 1] + + @doc """ + Renders a search trigger button that opens the universal search modal. + + ## Examples + + <.search_trigger /> + """ + attr :class, :string, default: "" + attr :rest, :global, include: ~w(phx-click phx-target) + + def search_trigger(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a search result item for records. + + ## Examples + + <.search_result_record record={record} selected={false} /> + """ + attr :record, :map, required: true + attr :selected, :boolean, default: false + attr :type, :atom, required: true, values: [:collection, :wishlist] + attr :rest, :global, include: ~w(phx-click phx-value-id) + + def search_result_record(assigns) do + ~H""" +
+
+
+ <.icon + name={if @type == :collection, do: "hero-musical-note", else: "hero-heart"} + class={"h-5 w-5 #{if @type == :collection, do: "text-green-500", else: "text-red-500"}"} + /> +
+
+

+ {@record.title} +

+

+ {@record.artists |> Enum.map(& &1.name) |> Enum.join(", ")} +

+
+
+
+ """ + end + + @doc """ + Renders a search result item for artists. + + ## Examples + + <.search_result_artist artist={artist} selected={false} /> + """ + attr :artist, :map, required: true + attr :selected, :boolean, default: false + attr :rest, :global, include: ~w(phx-click phx-value-id) + + def search_result_artist(assigns) do + ~H""" +
+
+
+ <.icon name="hero-user" class="h-5 w-5 text-blue-500" /> +
+
+

+ {@artist.name} +

+

+ {@artist.disambiguation} +

+
+
+
+ """ + end + + @doc """ + Renders a search result group with a title and items. + + ## Examples + + <.search_result_group title="Collection" count={3} total_count={10}> + <.search_result_record :for={record <- @records} record={record} /> + <:actions> + + + + """ + attr :title, :string, required: true + attr :count, :integer, required: true + attr :total_count, :integer, default: nil + attr :class, :string, default: "" + + slot :inner_block, required: true + slot :actions, doc: "Actions like 'view all' buttons" + + def search_result_group(assigns) do + ~H""" +
+

+ {@title} ({@count} + @count}> + of <%= @total_count %> + ) +

+ +
+ {render_slot(@inner_block)} +
+ +
+ {render_slot(action)} +
+
+ """ + end + + @doc """ + Renders a view all results button. + + ## Examples + + <.view_all_button count={25} target="collection" /> + """ + attr :count, :integer, required: true + attr :target, :string, required: true + attr :rest, :global, include: ~w(phx-click phx-value-query) + + def view_all_button(assigns) do + ~H""" + + """ + end + + @doc """ + Renders keyboard shortcut hints. + + ## Examples + + <.keyboard_shortcuts /> + """ + attr :total_results, :integer, default: 0 + + def keyboard_shortcuts(assigns) do + ~H""" +
+
+
+
+ ↑↓ + Navigate +
+
+ + Select +
+
+ Esc + Close +
+
+
0} class="text-gray-500 dark:text-gray-400"> + {@total_results} results +
+
+
+ """ + end + + @doc """ + Renders an empty search state. + + ## Examples + + <.empty_state /> + """ + def empty_state(assigns) do + ~H""" +
+ <.icon name="hero-magnifying-glass" class="h-12 w-12 text-gray-400 mx-auto mb-4" /> +

Start typing to search your records and artists

+

+ Use Ctrl+K + to open this search +

+
+ """ + end + + @doc """ + Renders a no results state. + + ## Examples + + <.no_results query="radiohead" /> + """ + attr :query, :string, required: true + + def no_results(assigns) do + ~H""" +
+ <.icon name="hero-face-frown" class="h-12 w-12 text-gray-400 mx-auto mb-4" /> +

No results found for "{@query}"

+

+ Try a different search term or check your spelling +

+
+ """ + end + + @doc """ + Renders a loading state. + + ## Examples + + <.loading_state /> + """ + def loading_state(assigns) do + ~H""" +
+
+

Searching...

+
+ """ + end +end diff --git a/lib/music_library_web/live/universal_search_live/index.ex b/lib/music_library_web/live/universal_search_live/index.ex new file mode 100644 index 00000000..a1118a44 --- /dev/null +++ b/lib/music_library_web/live/universal_search_live/index.ex @@ -0,0 +1,218 @@ +defmodule MusicLibraryWeb.UniversalSearchLive.Index do + use MusicLibraryWeb, :live_view + + alias MusicLibrary.Search + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:search_query, "") + |> assign(:search_results, %{collection: [], wishlist: [], artists: []}) + |> assign(:search_counts, %{collection_count: 0, wishlist_count: 0, artists_count: 0}) + |> assign(:show_modal, false) + |> assign(:loading, false) + |> assign(:selected_index, -1) + |> assign(:total_results, 0)} + end + + @impl true + def handle_event("open_modal", _params, socket) do + {:noreply, assign(socket, :show_modal, true)} + end + + @impl true + def handle_event("global_key_handler", %{"key" => "k", "ctrlKey" => true}, socket) do + {:noreply, assign(socket, :show_modal, true)} + end + + @impl true + def handle_event("global_key_handler", %{"key" => "k", "metaKey" => true}, socket) do + {:noreply, assign(socket, :show_modal, true)} + end + + @impl true + def handle_event("global_key_handler", _params, socket) do + {:noreply, socket} + end + + @impl true + def handle_event("close_modal", _params, socket) do + {:noreply, + socket + |> assign(:show_modal, false) + |> assign(:search_query, "") + |> assign(:search_results, %{collection: [], wishlist: [], artists: []}) + |> assign(:search_counts, %{collection_count: 0, wishlist_count: 0, artists_count: 0}) + |> assign(:selected_index, -1) + |> assign(:total_results, 0)} + end + + @impl true + def handle_event("search", %{"query" => query}, socket) do + socket = assign(socket, :search_query, query) + + case String.trim(query) do + "" -> + {:noreply, + socket + |> assign(:search_results, %{collection: [], wishlist: [], artists: []}) + |> assign(:search_counts, %{collection_count: 0, wishlist_count: 0, artists_count: 0}) + |> assign(:loading, false) + |> assign(:selected_index, -1) + |> assign(:total_results, 0)} + + _query -> + send(self(), {:perform_search, query}) + {:noreply, assign(socket, :loading, true)} + end + end + + @impl true + def handle_event("search", params, socket) when is_map(params) do + query = get_in(params, ["query"]) || "" + handle_event("search", %{"query" => query}, socket) + end + + @impl true + def handle_event("navigate_to_record", %{"id" => id, "type" => "collection"}, socket) do + {:noreply, + socket + |> assign(:show_modal, false) + |> push_navigate(to: ~p"/collection/#{id}")} + end + + @impl true + def handle_event("navigate_to_record", %{"id" => id, "type" => "wishlist"}, socket) do + {:noreply, + socket + |> assign(:show_modal, false) + |> push_navigate(to: ~p"/wishlist/#{id}")} + end + + @impl true + def handle_event("navigate_to_record", %{"id" => id}, socket) do + # Default to collection if type not specified + {:noreply, + socket + |> assign(:show_modal, false) + |> push_navigate(to: ~p"/collection/#{id}")} + end + + @impl true + def handle_event("navigate_to_artist", %{"id" => id}, socket) do + {:noreply, + socket + |> assign(:show_modal, false) + |> push_navigate(to: ~p"/artists/#{id}")} + end + + @impl true + def handle_event("navigate_to_collection", %{"query" => query}, socket) do + {:noreply, + socket + |> assign(:show_modal, false) + |> push_navigate(to: ~p"/collection?query=#{query}")} + end + + @impl true + def handle_event("navigate_to_wishlist", %{"query" => query}, socket) do + {:noreply, + socket + |> assign(:show_modal, false) + |> push_navigate(to: ~p"/wishlist?query=#{query}")} + end + + @impl true + def handle_event("key_navigation", %{"key" => key}, socket) do + case key do + "Escape" -> + {:noreply, assign(socket, :show_modal, false)} + + "ArrowDown" -> + new_index = min(socket.assigns.selected_index + 1, socket.assigns.total_results - 1) + {:noreply, assign(socket, :selected_index, new_index)} + + "ArrowUp" -> + new_index = max(socket.assigns.selected_index - 1, -1) + {:noreply, assign(socket, :selected_index, new_index)} + + "Enter" -> + handle_enter_key(socket) + + _ -> + {:noreply, socket} + end + end + + @impl true + def handle_info({:perform_search, query}, socket) do + # Only search if the query hasn't changed (debouncing) + if query == socket.assigns.search_query do + search_results = Search.universal_search(query, limit: 5) + search_counts = Search.search_counts(query) + + total_results = + length(search_results.collection) + + length(search_results.wishlist) + + length(search_results.artists) + + {:noreply, + socket + |> assign(:search_results, search_results) + |> assign(:search_counts, search_counts) + |> assign(:loading, false) + |> assign(:selected_index, -1) + |> assign(:total_results, total_results)} + else + {:noreply, socket} + end + end + + defp handle_enter_key(socket) do + selected_index = socket.assigns.selected_index + + if selected_index >= 0 do + results = socket.assigns.search_results + + # Calculate which section and item the selected index corresponds to + collection_count = length(results.collection) + wishlist_count = length(results.wishlist) + artists_count = length(results.artists) + + cond do + selected_index < collection_count -> + # Selected item is in collection + record = Enum.at(results.collection, selected_index) + + {:noreply, + socket + |> assign(:show_modal, false) + |> push_navigate(to: ~p"/collection/#{record.id}")} + + selected_index < collection_count + wishlist_count -> + # Selected item is in wishlist + record = Enum.at(results.wishlist, selected_index - collection_count) + + {:noreply, + socket + |> assign(:show_modal, false) + |> push_navigate(to: ~p"/wishlist/#{record.id}")} + + selected_index < collection_count + wishlist_count + artists_count -> + # Selected item is in artists + artist = Enum.at(results.artists, selected_index - collection_count - wishlist_count) + + {:noreply, + socket + |> assign(:show_modal, false) + |> push_navigate(to: ~p"/artists/#{artist.musicbrainz_id}")} + + true -> + {:noreply, socket} + end + else + {:noreply, socket} + end + end +end diff --git a/lib/music_library_web/live/universal_search_live/index.html.heex b/lib/music_library_web/live/universal_search_live/index.html.heex new file mode 100644 index 00000000..ff6bbc71 --- /dev/null +++ b/lib/music_library_web/live/universal_search_live/index.html.heex @@ -0,0 +1,147 @@ +
+
+ +
+
+ + +
+ +
+
+
+ <.icon + name="hero-magnifying-glass" + class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" + /> + + +
+
+
+ + + + + + + + + + + +
0 and not @loading} class="max-h-96 overflow-y-auto"> + + 0} + title="Collection" + count={length(@search_results.collection)} + total_count={@search_counts.collection_count} + > + + <:actions> + length(@search_results.collection)} + count={@search_counts.collection_count} + target="collection" + phx-click="navigate_to_collection" + phx-value-query={@search_query} + /> + + + + + 0} + title="Wishlist" + count={length(@search_results.wishlist)} + total_count={@search_counts.wishlist_count} + class="border-t border-gray-200 dark:border-gray-700" + > + + <:actions> + length(@search_results.wishlist)} + count={@search_counts.wishlist_count} + target="wishlist" + phx-click="navigate_to_wishlist" + phx-value-query={@search_query} + /> + + + + + 0} + title="Artists" + count={length(@search_results.artists)} + total_count={@search_counts.artists_count} + class="border-t border-gray-200 dark:border-gray-700" + > + + +
+ + + +
+
+