First draft: universal search (Claude)
# 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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -38,6 +38,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-2 md:space-x-4 items-center">
|
||||
<MusicLibraryWeb.SearchComponents.search_trigger
|
||||
phx-click="open_modal"
|
||||
phx-target="#universal-search"
|
||||
/>
|
||||
<.dropdown placement="bottom-end">
|
||||
<:toggle>
|
||||
<button class="flex items-center gap-x-2">
|
||||
@@ -108,3 +112,10 @@
|
||||
{@inner_content}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div phx-hook="Search" id="search-global-hook">
|
||||
{live_render(@socket, MusicLibraryWeb.UniversalSearchLive.Index,
|
||||
id: "universal-search",
|
||||
session: %{}
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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"""
|
||||
<button
|
||||
type="button"
|
||||
class={[
|
||||
"flex items-center justify-center h-9 w-9 rounded-lg",
|
||||
"text-gray-500 dark:text-gray-400",
|
||||
"hover:text-gray-700 dark:hover:text-gray-300",
|
||||
"hover:bg-gray-100 dark:hover:bg-gray-800",
|
||||
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900",
|
||||
"transition-colors duration-200",
|
||||
@class
|
||||
]}
|
||||
title="Search (Ctrl+K)"
|
||||
{@rest}
|
||||
>
|
||||
<.icon name="hero-magnifying-glass" class="h-5 w-5" />
|
||||
</button>
|
||||
"""
|
||||
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"""
|
||||
<div
|
||||
class={[
|
||||
"p-3 rounded-lg cursor-pointer transition-colors",
|
||||
"hover:bg-gray-50 dark:hover:bg-gray-700",
|
||||
if(@selected, do: "bg-blue-50 dark:bg-blue-900", else: "")
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<.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"}"}
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{@record.title}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{@record.artists |> Enum.map(& &1.name) |> Enum.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
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"""
|
||||
<div
|
||||
class={[
|
||||
"p-3 rounded-lg cursor-pointer transition-colors",
|
||||
"hover:bg-gray-50 dark:hover:bg-gray-700",
|
||||
if(@selected, do: "bg-blue-50 dark:bg-blue-900", else: "")
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<.icon name="hero-user" class="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{@artist.name}
|
||||
</p>
|
||||
<p :if={@artist.disambiguation} class="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{@artist.disambiguation}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
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>
|
||||
<button phx-click="view_all">View all results</button>
|
||||
</:actions>
|
||||
</.search_result_group>
|
||||
"""
|
||||
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"""
|
||||
<div class={["p-4", @class]}>
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">
|
||||
{@title} ({@count}
|
||||
<span :if={@total_count && @total_count > @count}>
|
||||
of <%= @total_count %>
|
||||
</span>)
|
||||
</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
|
||||
<div :for={action <- @actions} class="mt-3">
|
||||
{render_slot(action)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
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"""
|
||||
<button
|
||||
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors"
|
||||
{@rest}
|
||||
>
|
||||
View all {@count} {@target} results →
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders keyboard shortcut hints.
|
||||
|
||||
## Examples
|
||||
|
||||
<.keyboard_shortcuts />
|
||||
"""
|
||||
attr :total_results, :integer, default: 0
|
||||
|
||||
def keyboard_shortcuts(assigns) do
|
||||
~H"""
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-900 rounded-b-lg border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex items-center">
|
||||
<kbd class="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded">↑↓</kbd>
|
||||
<span class="ml-1">Navigate</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<kbd class="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded">↵</kbd>
|
||||
<span class="ml-1">Select</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<kbd class="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded">Esc</kbd>
|
||||
<span class="ml-1">Close</span>
|
||||
</div>
|
||||
</div>
|
||||
<div :if={@total_results > 0} class="text-gray-500 dark:text-gray-400">
|
||||
{@total_results} results
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an empty search state.
|
||||
|
||||
## Examples
|
||||
|
||||
<.empty_state />
|
||||
"""
|
||||
def empty_state(assigns) do
|
||||
~H"""
|
||||
<div class="p-8 text-center">
|
||||
<.icon name="hero-magnifying-glass" class="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p class="text-gray-600 dark:text-gray-400">Start typing to search your records and artists</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500 mt-2">
|
||||
Use <kbd class="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-xs">Ctrl+K</kbd>
|
||||
to open this search
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a no results state.
|
||||
|
||||
## Examples
|
||||
|
||||
<.no_results query="radiohead" />
|
||||
"""
|
||||
attr :query, :string, required: true
|
||||
|
||||
def no_results(assigns) do
|
||||
~H"""
|
||||
<div class="p-8 text-center">
|
||||
<.icon name="hero-face-frown" class="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p class="text-gray-600 dark:text-gray-400">No results found for "{@query}"</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500 mt-2">
|
||||
Try a different search term or check your spelling
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a loading state.
|
||||
|
||||
## Examples
|
||||
|
||||
<.loading_state />
|
||||
"""
|
||||
def loading_state(assigns) do
|
||||
~H"""
|
||||
<div class="p-8 text-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-600 dark:text-gray-400">Searching...</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,147 @@
|
||||
<div phx-window-keydown="global_key_handler" phx-key="keydown" id="universal-search-root">
|
||||
<div
|
||||
:if={@show_modal}
|
||||
class="fixed inset-0 z-50 flex items-start justify-center pt-16 px-4 pb-6 sm:pt-24"
|
||||
phx-window-keydown="key_navigation"
|
||||
phx-key="keydown"
|
||||
phx-hook="Search"
|
||||
id="search-modal"
|
||||
>
|
||||
<!-- Modal backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
|
||||
phx-click="close_modal"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Modal content -->
|
||||
<div class="relative w-full max-w-2xl bg-white dark:bg-gray-800 rounded-lg shadow-2xl">
|
||||
<!-- Search input -->
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<form phx-change="search" phx-submit="search">
|
||||
<div class="relative">
|
||||
<.icon
|
||||
name="hero-magnifying-glass"
|
||||
class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="query"
|
||||
placeholder="Search records and artists..."
|
||||
value={@search_query}
|
||||
phx-debounce="300"
|
||||
class="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg
|
||||
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-400
|
||||
placeholder-gray-500 dark:placeholder-gray-400"
|
||||
phx-hook="SearchInput"
|
||||
id="universal-search-input"
|
||||
/>
|
||||
<button
|
||||
:if={@search_query != ""}
|
||||
type="button"
|
||||
phx-click="search"
|
||||
phx-value-query=""
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<MusicLibraryWeb.SearchComponents.loading_state :if={@loading} />
|
||||
|
||||
<!-- Empty state -->
|
||||
<MusicLibraryWeb.SearchComponents.empty_state :if={@search_query == "" and not @loading} />
|
||||
|
||||
<!-- No results -->
|
||||
<MusicLibraryWeb.SearchComponents.no_results
|
||||
:if={@search_query != "" and @total_results == 0 and not @loading}
|
||||
query={@search_query}
|
||||
/>
|
||||
|
||||
<!-- Search results -->
|
||||
<div :if={@total_results > 0 and not @loading} class="max-h-96 overflow-y-auto">
|
||||
<!-- Collection results -->
|
||||
<MusicLibraryWeb.SearchComponents.search_result_group
|
||||
:if={length(@search_results.collection) > 0}
|
||||
title="Collection"
|
||||
count={length(@search_results.collection)}
|
||||
total_count={@search_counts.collection_count}
|
||||
>
|
||||
<MusicLibraryWeb.SearchComponents.search_result_record
|
||||
:for={{record, index} <- Enum.with_index(@search_results.collection)}
|
||||
record={record}
|
||||
type={:collection}
|
||||
selected={@selected_index == index}
|
||||
phx-click="navigate_to_record"
|
||||
phx-value-id={record.id}
|
||||
phx-value-type="collection"
|
||||
/>
|
||||
<:actions>
|
||||
<MusicLibraryWeb.SearchComponents.view_all_button
|
||||
:if={@search_counts.collection_count > length(@search_results.collection)}
|
||||
count={@search_counts.collection_count}
|
||||
target="collection"
|
||||
phx-click="navigate_to_collection"
|
||||
phx-value-query={@search_query}
|
||||
/>
|
||||
</:actions>
|
||||
</MusicLibraryWeb.SearchComponents.search_result_group>
|
||||
|
||||
<!-- Wishlist results -->
|
||||
<MusicLibraryWeb.SearchComponents.search_result_group
|
||||
:if={length(@search_results.wishlist) > 0}
|
||||
title="Wishlist"
|
||||
count={length(@search_results.wishlist)}
|
||||
total_count={@search_counts.wishlist_count}
|
||||
class="border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<MusicLibraryWeb.SearchComponents.search_result_record
|
||||
:for={{record, index} <- Enum.with_index(@search_results.wishlist)}
|
||||
record={record}
|
||||
type={:wishlist}
|
||||
selected={@selected_index == length(@search_results.collection) + index}
|
||||
phx-click="navigate_to_record"
|
||||
phx-value-id={record.id}
|
||||
phx-value-type="wishlist"
|
||||
/>
|
||||
<:actions>
|
||||
<MusicLibraryWeb.SearchComponents.view_all_button
|
||||
:if={@search_counts.wishlist_count > length(@search_results.wishlist)}
|
||||
count={@search_counts.wishlist_count}
|
||||
target="wishlist"
|
||||
phx-click="navigate_to_wishlist"
|
||||
phx-value-query={@search_query}
|
||||
/>
|
||||
</:actions>
|
||||
</MusicLibraryWeb.SearchComponents.search_result_group>
|
||||
|
||||
<!-- Artists results -->
|
||||
<MusicLibraryWeb.SearchComponents.search_result_group
|
||||
:if={length(@search_results.artists) > 0}
|
||||
title="Artists"
|
||||
count={length(@search_results.artists)}
|
||||
total_count={@search_counts.artists_count}
|
||||
class="border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<MusicLibraryWeb.SearchComponents.search_result_artist
|
||||
:for={{artist, index} <- Enum.with_index(@search_results.artists)}
|
||||
artist={artist}
|
||||
selected={
|
||||
@selected_index ==
|
||||
length(@search_results.collection) + length(@search_results.wishlist) + index
|
||||
}
|
||||
phx-click="navigate_to_artist"
|
||||
phx-value-id={artist.musicbrainz_id}
|
||||
/>
|
||||
</MusicLibraryWeb.SearchComponents.search_result_group>
|
||||
</div>
|
||||
|
||||
<!-- Footer with keyboard shortcuts -->
|
||||
<MusicLibraryWeb.SearchComponents.keyboard_shortcuts total_results={@total_results} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user