Implement dynamic navbar autocomplete search functionality

- Add real-time autocomplete endpoint with fast name-only queries
- Replace static search form with interactive Alpine.js component
- Include 300ms debounced search with keyboard navigation support
- Add instructional help dropdown when search input is focused but empty
- Implement proper content type icons with hex colors instead of CSS classes
- Add database indexes for optimized name searches across all content types
- Support arrow key navigation, Enter to select/search, Escape to close
- Maintain full search functionality as fallback for comprehensive results

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andrew Brown 2025-07-06 22:49:33 -07:00
parent 96500df2ec
commit c3bb6f453b
4 changed files with 307 additions and 15 deletions

View File

@ -129,6 +129,64 @@ class SearchController < ApplicationController
@seen_result_pages = {}
end
def autocomplete
@query = params[:q]&.strip
# Return empty results for short or empty queries
if @query.blank? || @query.length < 2
render json: []
return
end
results = []
# Fast name-only search across all content types
Rails.application.config.content_types[:all].each do |content_type|
begin
model_class = content_type.name.constantize
# Only search content types that have name columns
if model_class.column_names.include?('name')
# Fast query with limit to prevent slow searches
matching_entities = model_class
.where(user_id: current_user.id)
.where("LOWER(name) LIKE LOWER(?)", "%#{@query}%")
.limit(5) # Limit per content type
.select(:id, :name, :created_at) # Only select needed columns
matching_entities.each do |entity|
results << {
id: entity.id,
name: entity.name,
type: content_type.name,
icon: content_type.icon,
color: content_type.hex_color,
url: main_app.polymorphic_path(entity),
created_at: entity.created_at
}
end
end
rescue => e
# Skip any content types that have issues
Rails.logger.debug "Autocomplete: Skipping #{content_type.name}: #{e.message}"
end
# Stop if we have enough results
break if results.size >= 15
end
# Sort by relevance: exact matches first, then by name
results.sort_by! do |result|
exact_match = result[:name].downcase == @query.downcase ? 0 : 1
[exact_match, result[:name].downcase]
end
# Limit total results
results = results.first(12)
render json: results
end
helper_method :highlight_search_terms
private

View File

@ -27,21 +27,98 @@
<!-- right-hand side of navbar -->
<div class="pr-2 flex items-center justify-end space-x-1 sm:space-x-2 ml-auto h-14" x-data="{ notificationsDropdownOpen: false, accountDropdownOpen: false }">
<% if user_signed_in? %>
<%= form_tag main_app.search_path, method: :get, class: 'hidden md:inline-flex items-center relative' do %>
<!-- Dynamic Autocomplete Search -->
<div class="hidden md:inline-flex items-center relative"
x-data="searchAutocomplete()"
x-init="init()"
@click.away="closeDropdown()">
<input class="w-32 lg:w-48 xl:w-96 border-2 border-blue-400 text-notebook-blue focus:border-notebook-blue bg-white h-9 px-3 text-sm rounded focus:outline-none"
type="search" name="q" value="<%= params[:q] %>" placeholder="Search your notebook">
<!--
<button type="submit" class="relative right-8 top-0.5 mt-4 mr-4">
<svg class="text-gray-600 hover:text-notebook-blue h-4 w-4 fill-current" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px"
viewBox="0 0 56.966 56.966" style="enable-background:new 0 0 56.966 56.966;" xml:space="preserve"
width="512px" height="512px">
<path
d="M55.146,51.887L41.588,37.786c3.486-4.144,5.396-9.358,5.396-14.786c0-12.682-10.318-23-23-23s-23,10.318-23,23 s10.318,23,23,23c4.761,0,9.298-1.436,13.177-4.162l13.661,14.208c0.571,0.593,1.339,0.92,2.162,0.92 c0.779,0,1.518-0.297,2.079-0.837C56.255,54.982,56.293,53.08,55.146,51.887z M23.984,6c9.374,0,17,7.626,17,17s-7.626,17-17,17 s-17-7.626-17-17S14.61,6,23.984,6z" />
</svg>
</button>
-->
<% end %>
type="search"
x-model="query"
@input="handleInput()"
@keydown="handleKeydown($event)"
@focus="handleFocus()"
placeholder="Search your notebook"
autocomplete="off">
<!-- Autocomplete Dropdown -->
<div x-show="showDropdown"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute top-full left-0 right-0 mt-1 bg-white rounded-md shadow-lg border border-gray-200 z-50 max-h-80 overflow-y-auto">
<!-- Help text when input is focused but empty -->
<div x-show="query.length === 0" class="px-4 py-4 text-sm text-gray-600 bg-blue-50 border-b border-blue-100">
<div class="flex items-start space-x-3">
<i class="material-icons text-blue-500 text-lg">info</i>
<div class="space-y-2">
<div class="font-medium text-gray-800">Quick Search Tips:</div>
<ul class="space-y-1 text-xs">
<li class="flex items-center">
<i class="material-icons text-xs mr-2 text-gray-400">keyboard</i>
Start typing to see your pages as suggestions
</li>
<li class="flex items-center">
<i class="material-icons text-xs mr-2 text-gray-400">mouse</i>
Click on any result to quickly navigate to it
</li>
<li class="flex items-center">
<i class="material-icons text-xs mr-2 text-gray-400">search</i>
Press Enter to search all content in your notebook
</li>
</ul>
</div>
</div>
</div>
<!-- Loading state -->
<div x-show="loading && query.length >= 2" class="px-4 py-3 text-sm text-gray-500">
<i class="material-icons text-sm animate-spin">refresh</i>
Searching...
</div>
<!-- Results -->
<template x-for="(result, index) in results" :key="result.id + '_' + result.type">
<a :href="result.url"
class="flex items-center px-4 py-3 hover:bg-gray-50 border-b border-gray-100 last:border-b-0 transition-colors"
:class="{ 'bg-blue-50': selectedIndex === index }"
@mouseenter="selectedIndex = index">
<i class="material-icons text-sm mr-3 rounded-full p-1 w-8 h-8 text-white flex items-center justify-center"
:style="`background-color: ${result.color}`"
x-text="result.icon"></i>
<div class="flex-grow min-w-0">
<div class="font-medium text-gray-900 truncate" x-text="result.name"></div>
<div class="text-xs text-gray-500" x-text="result.type"></div>
</div>
<i class="material-icons text-gray-400 text-sm">arrow_forward</i>
</a>
</template>
<!-- No results message -->
<div x-show="!loading && results.length === 0 && query.length >= 2"
class="px-4 py-3 text-sm text-gray-500">
No pages found matching "<span x-text="query"></span>"
</div>
<!-- Full search link -->
<div x-show="query.length >= 2"
class="border-t border-gray-200 bg-gray-50">
<a :href="`<%= main_app.search_path %>?q=${encodeURIComponent(query)}`"
class="flex items-center px-4 py-3 text-sm text-blue-600 hover:text-blue-800 hover:bg-gray-100 font-medium">
<i class="material-icons text-sm mr-2">search</i>
Search my notebook for "<span x-text="query"></span>"
</a>
</div>
</div>
</div>
<div class="inline-flex items-center">
@ -136,4 +213,113 @@
</div>
<% end %>
</div>
</header>
</header>
<script>
function searchAutocomplete() {
return {
query: '',
results: [],
loading: false,
showDropdown: false,
selectedIndex: -1,
debounceTimer: null,
init() {
// Initialize with any existing query parameter
const urlParams = new URLSearchParams(window.location.search);
const q = urlParams.get('q');
if (q) {
this.query = q;
}
},
handleInput() {
clearTimeout(this.debounceTimer);
if (this.query.length < 2) {
this.results = [];
this.showDropdown = false;
return;
}
this.debounceTimer = setTimeout(() => {
this.fetchResults();
}, 300); // 300ms debounce
},
handleFocus() {
this.showDropdown = true;
if (this.query.length >= 2 && this.results.length === 0) {
this.fetchResults();
}
},
handleKeydown(event) {
if (!this.showDropdown) return;
switch(event.key) {
case 'ArrowDown':
event.preventDefault();
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
break;
case 'Enter':
event.preventDefault();
if (this.selectedIndex >= 0 && this.results[this.selectedIndex]) {
// Navigate to selected result
window.location.href = this.results[this.selectedIndex].url;
} else {
// Fall back to full search results page
window.location.href = `<%= main_app.search_path %>?q=${encodeURIComponent(this.query)}`;
}
break;
case 'Escape':
this.closeDropdown();
event.target.blur();
break;
}
},
closeDropdown() {
this.showDropdown = false;
this.selectedIndex = -1;
},
async fetchResults() {
if (this.query.length < 2) return;
this.loading = true;
this.showDropdown = true;
try {
const response = await fetch(`<%= main_app.search_autocomplete_path %>?q=${encodeURIComponent(this.query)}`, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
});
if (response.ok) {
this.results = await response.json();
this.selectedIndex = -1; // Reset selection when new results arrive
} else {
console.error('Autocomplete request failed:', response.status);
this.results = [];
}
} catch (error) {
console.error('Autocomplete error:', error);
this.results = [];
} finally {
this.loading = false;
}
}
}
}
</script>

View File

@ -354,6 +354,7 @@ Rails.application.routes.draw do
end
get 'search/', to: 'search#results'
get 'search/autocomplete', to: 'search#autocomplete'
authenticate :user, lambda { |u| u.site_administrator? || Rails.env.development? } do
scope 'admin' do

View File

@ -0,0 +1,47 @@
class AddNameIndexesToContentTypes < ActiveRecord::Migration[6.1]
def up
# Add optimized name search indexes for all content types
content_tables = %w[
universes characters locations items buildings conditions continents countries creatures deities
floras foods governments groups jobs landmarks languages lores magics planets races religions
scenes schools sports technologies towns traditions vehicles
]
content_tables.each do |table_name|
next unless table_exists?(table_name)
next unless column_exists?(table_name, :name)
next unless column_exists?(table_name, :user_id)
if ActiveRecord::Base.connection.adapter_name.downcase.include?('postgresql')
# PostgreSQL: Add trigram index for fast LIKE searches
begin
execute "CREATE EXTENSION IF NOT EXISTS pg_trgm"
add_index table_name, [:user_id, :name], name: "idx_#{table_name}_user_name"
add_index table_name, :name, using: :gin, opclass: {name: :gin_trgm_ops}, name: "idx_#{table_name}_name_trgm"
rescue => e
Rails.logger.warn "Could not add trigram index to #{table_name}: #{e.message}"
# Fallback to regular index
add_index table_name, [:user_id, :name], name: "idx_#{table_name}_user_name" unless index_exists?(table_name, [:user_id, :name])
end
else
# SQLite and other databases: Regular indexes
add_index table_name, [:user_id, :name], name: "idx_#{table_name}_user_name" unless index_exists?(table_name, [:user_id, :name])
end
end
end
def down
content_tables = %w[
universes characters locations items buildings conditions continents countries creatures deities
floras foods governments groups jobs landmarks languages lores magics planets races religions
scenes schools sports technologies towns traditions vehicles
]
content_tables.each do |table_name|
next unless table_exists?(table_name)
remove_index table_name, name: "idx_#{table_name}_user_name" if index_exists?(table_name, [:user_id, :name], name: "idx_#{table_name}_user_name")
remove_index table_name, name: "idx_#{table_name}_name_trgm" if index_exists?(table_name, :name, name: "idx_#{table_name}_name_trgm")
end
end
end