Overview
Added a global search feature that searches across albums, photos, members, events, and tags using PostgreSQL full-text search. Accessible via a modal triggered by ⌘K (Mac) or Ctrl+K (Windows/Linux), with keyboard navigation and results styled to match existing card components.
Also added sound effects for incoming notifications with intelligent debouncing to prevent audio spam during notification bursts.
Database: Full-Text Search
PostgreSQL FTS Setup
File: supabase/migrations/20260125000000_add_global_search.sql
Added tsvector columns with weighted ranking to searchable tables:
-- Profiles: full_name and nickname get highest weight
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(full_name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(nickname, '')), 'A') ||
setweight(to_tsvector('english', coalesce(bio, '')), 'B')
) STORED;
-- GIN indexes for fast lookups
CREATE INDEX IF NOT EXISTS idx_profiles_search ON profiles USING GIN(search_vector);
Unified Search RPC
Created global_search function that:
- Searches across all entity types in a single query
- Returns ranked results with entity metadata
- Respects visibility rules (is_public, deleted_at, suspended_at)
- Uses prefix matching for tags (e.g., "nat" matches "nature")
CREATE OR REPLACE FUNCTION public.global_search(
search_query text,
result_limit int DEFAULT 20,
search_types text[] DEFAULT ARRAY['albums', 'photos', 'members', 'events', 'tags']
)
RETURNS TABLE(
entity_type text,
entity_id text,
title text,
subtitle text,
image_url text,
url text,
rank real
)
Special handling:
- Albums: Subtitle shows photo count ("12 photos")
- Events: Uses
COALESCE(NULLIF(cover_image, ''), NULLIF(image_url, ''))to match EventImage component logic - Tags: Shows photo count and uses prefix matching
Search UI Components
SearchModal
File: src/components/search/SearchModal.tsx
Modal component with:
- Focus trap for accessibility
- Keyboard navigation (↑↓ arrows, Enter to select, Esc to close)
- Selected item tracking with visual highlight
- Responsive footer (hidden on mobile)
// Handle keyboard shortcuts
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex((prev) => prev < results.length - 1 ? prev + 1 : prev);
break;
case 'Enter':
if (selectedIndex >= 0) {
navigateToResult(selectedIndex);
}
break;
}
};
SearchResultItem
File: src/components/search/SearchResultItem.tsx
Entity-specific styling to match existing cards:
- Members: Uses
Avatarcomponent like MemberCard - Albums: Matches
AlbumMiniCardwith square thumbnail - Photos: Square thumbnail with hover brightness effect
- Events: 4:3 thumbnail with calendar icon fallback
- Tags: Tag icon with photo count
Each result type has selected state styling:
className={clsx(
'group flex items-center gap-3 rounded-lg border p-3 transition-colors',
isSelected
? 'border-primary bg-background'
: 'border-border-color bg-background-light hover:border-primary hover:bg-background',
)}
Search Hook with Debouncing
File: src/hooks/useSearch.ts
Problem: Loading state flickered because:
isPendingSearch(query !== debouncedQuery) would become false- But
isLoading(fetch in progress) wasn't true yet - Brief moment where neither was true = flicker
Solution: Track lastSearchedQuery to know when we're waiting for results:
// Loading if:
// 1. Query is pending debounce (user is typing)
// 2. OR: debouncedQuery is valid but we haven't got results for it yet
const isPendingDebounce = query.trim().length >= minQueryLength && query !== debouncedQuery;
const isWaitingForResults = debouncedQuery.trim().length >= minQueryLength && debouncedQuery !== lastSearchedQuery;
const isLoading = isPendingDebounce || isWaitingForResults;
Also uses AbortController to cancel pending requests when query changes.
Header Integration
File: src/components/layout/Header.tsx
Added search button with OS-aware keyboard shortcut display:
// Detect Mac vs other OS for keyboard shortcut display
const isMac = useMemo(() => {
if (!mounted) return true; // Default to Mac symbol for SSR
return navigator.platform.toLowerCase().includes('mac');
}, [mounted]);
Displays ⌘K on Mac, Ctrl K on Windows/Linux.
Global keyboard shortcut listener:
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setSearchOpen(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
Cache Invalidation
File: src/app/actions/revalidate.ts
Added revalidateTag('search', 'max') to existing revalidation functions so search results are invalidated when content changes:
revalidateEvent()- when events changerevalidateAlbum()- when albums changerevalidateGallery()- when photos changerevalidateProfiles()- when profiles changerevalidateAll()- full invalidation
Also added dedicated revalidateSearch() function.
All Modified Files (14 total)
New Files (10)
supabase/migrations/20260125000000_add_global_search.sql- FTS columns, indexes, and RPC functionsrc/types/search.ts- SearchResult and SearchEntityType typessrc/lib/data/search.ts- Cached searchEntities functionsrc/app/api/search/route.ts- GET endpoint with query/types/limit paramssrc/hooks/useSearch.ts- Search state management with debouncingsrc/hooks/useDebounce.ts- Generic debounce hooksrc/components/search/SearchModal.tsx- Modal with keyboard navigationsrc/components/search/SearchInput.tsx- Input with search icon and clear buttonsrc/components/search/SearchResults.tsx- Grouped results with loading skeletonsrc/components/search/SearchResultItem.tsx- Entity-specific result rendering
Modified Files (4)
src/components/layout/Header.tsx- Search button, modal, and ⌘K shortcutsrc/app/actions/revalidate.ts- Add search cache invalidationsrc/lib/data/index.ts- Export search functionssrc/hooks/useRealtimeNotifications.tsx- Add notification sound effects with debouncing
Notification Sound Effects
File: src/hooks/useRealtimeNotifications.tsx
Added audio feedback for incoming notifications:
Sound Playback
- Plays
/cpg-notification.mp3when a notification arrives via Supabase Realtime - Volume set to 70% to avoid being too loud
- Gracefully handles browser autoplay restrictions (fails silently if audio can't play)
Debouncing Logic
To prevent audio spam when multiple notifications arrive quickly:
- Tracks the last time the sound was played
- Only plays sound if more than 3 seconds have passed since the last play
- If multiple notifications arrive within 3 seconds, only the first one triggers the sound
- Subsequent notifications are silently ignored until the 3-second window expires
// Track last sound play time to debounce sound effects
let lastSoundPlayTime = 0;
const NOTIFICATION_SOUND_WINDOW_MS = 3000; // 3 seconds
function handleNotificationSound(): void {
const now = Date.now();
// Only play if more than 3 seconds have passed since last sound
if (now - lastSoundPlayTime < NOTIFICATION_SOUND_WINDOW_MS) {
return;
}
lastSoundPlayTime = now;
// Play sound...
}
This ensures users aren't overwhelmed by sound effects during notification bursts (e.g., when multiple people like a photo simultaneously), while still providing audio feedback for individual notifications.
Testing
Search Functionality
- Press ⌘K (Mac) or Ctrl+K (Windows) to open search
- Type at least 2 characters to trigger search
- Verify results are grouped by type (Members, Albums, Photos, Events, Tags)
- Use ↑↓ arrows to navigate, Enter to select
- Click a result or press Enter to navigate
Loading States
- Type quickly and verify no flicker between skeleton and results
- Verify skeleton shows immediately when typing
- Verify "No results found" only shows after search completes with no matches
Keyboard Navigation
- Verify selected item has primary border highlight
- Verify arrows wrap correctly at top/bottom
- Verify Esc closes the modal
- Verify clicking outside closes the modal