This repository has no description
1'use client';
2
3import React, { useState, useEffect, useRef } from 'react';
4import { useRouter } from 'next/navigation';
5import styles from './ProfileSearch.module.css';
6
7type UserSuggestion = {
8 did: string;
9 handle: string;
10 displayName?: string;
11 avatar?: string | null;
12};
13
14export default function ProfileSearch() {
15 const [query, setQuery] = useState('');
16 const [suggestions, setSuggestions] = useState<UserSuggestion[]>([]);
17 const [loading, setLoading] = useState(false);
18 const [showSuggestions, setShowSuggestions] = useState(false);
19 const [placeholder, setPlaceholder] = useState('Search user @handle');
20 const suggestionsRef = useRef<HTMLDivElement>(null);
21 const inputRef = useRef<HTMLInputElement>(null);
22 const router = useRouter();
23 const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
24
25 // Update placeholder text based on screen width
26 useEffect(() => {
27 const updatePlaceholder = () => {
28 if (window.innerWidth <= 480) {
29 setPlaceholder('Search handle');
30 } else {
31 setPlaceholder('Search handle');
32 }
33 };
34
35 // Initial check
36 updatePlaceholder();
37
38 // Listen for resize events
39 window.addEventListener('resize', updatePlaceholder);
40
41 // Cleanup
42 return () => window.removeEventListener('resize', updatePlaceholder);
43 }, []);
44
45 // Close suggestions when clicking outside
46 useEffect(() => {
47 const handleClickOutside = (event: MouseEvent) => {
48 if (
49 suggestionsRef.current &&
50 !suggestionsRef.current.contains(event.target as Node) &&
51 !inputRef.current?.contains(event.target as Node)
52 ) {
53 setShowSuggestions(false);
54 }
55 };
56
57 document.addEventListener('mousedown', handleClickOutside);
58 return () => {
59 document.removeEventListener('mousedown', handleClickOutside);
60 };
61 }, []);
62
63 // Enable suggestions with debouncing
64 useEffect(() => {
65 // Clear previous timer if it exists
66 if (debounceTimerRef.current) {
67 clearTimeout(debounceTimerRef.current);
68 }
69
70 // Don't search for very short queries
71 if (!query || query.length < 2) {
72 setSuggestions([]);
73 setShowSuggestions(false);
74 return;
75 }
76
77 // Set a debounce timer to avoid too many requests
78 debounceTimerRef.current = setTimeout(async () => {
79 try {
80 setLoading(true);
81
82 // Format the query - remove @ if it exists
83 const searchQuery = query.trim().startsWith('@')
84 ? query.trim().substring(1)
85 : query.trim();
86
87 // Call the Bluesky API for typeahead suggestions
88 const response = await fetch(
89 `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(searchQuery)}&limit=5`
90 );
91
92 if (response.ok) {
93 const data = await response.json();
94 if (data.actors && Array.isArray(data.actors)) {
95 // Map to our UserSuggestion type
96 setSuggestions(data.actors.map((actor: any) => ({
97 did: actor.did,
98 handle: actor.handle,
99 displayName: actor.displayName,
100 avatar: actor.avatar
101 })));
102 setShowSuggestions(true);
103 }
104 } else {
105 console.error('Failed to fetch suggestions:', await response.text());
106 }
107 } catch (error) {
108 console.error('Error fetching suggestions:', error);
109 } finally {
110 setLoading(false);
111 }
112 }, 300); // 300ms debounce delay
113
114 return () => {
115 if (debounceTimerRef.current) {
116 clearTimeout(debounceTimerRef.current);
117 }
118 };
119 }, [query]);
120
121 const handleSearch = (e: React.FormEvent) => {
122 e.preventDefault();
123 if (query.trim()) {
124 // Normalize the handle by removing @ if present
125 const handle = query.trim().startsWith('@')
126 ? query.trim().substring(1)
127 : query.trim();
128
129 router.push(`/profile/${handle}`);
130 setShowSuggestions(false);
131 }
132 };
133
134 // Handle clicking on a suggestion
135 const handleSuggestionClick = (suggestion: UserSuggestion) => {
136 router.push(`/profile/${suggestion.handle}`);
137 setShowSuggestions(false);
138 setQuery(''); // Clear the input
139 };
140
141 return (
142 <div className={styles.searchContainer}>
143 <form onSubmit={handleSearch} className={styles.searchForm}>
144 <input
145 ref={inputRef}
146 type="text"
147 value={query}
148 onChange={(e) => setQuery(e.target.value)}
149 placeholder={placeholder}
150 className={`${styles.searchInput} font-regular`}
151 aria-label="Search for a user profile"
152 />
153 <button type="submit" className={`${styles.searchButton} font-medium`}>
154 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
155 <circle cx="11" cy="11" r="8"></circle>
156 <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
157 </svg>
158 </button>
159 </form>
160
161 {/* Suggestions dropdown */}
162 {showSuggestions && (
163 <div className={styles.suggestionsContainer} ref={suggestionsRef}>
164 {loading ? (
165 <div className={styles.loadingContainer}>
166 <div className={styles.loadingDot}></div>
167 <div className={styles.loadingDot}></div>
168 <div className={styles.loadingDot}></div>
169 </div>
170 ) : suggestions.length > 0 ? (
171 <ul className={styles.suggestionsList}>
172 {suggestions.map((suggestion) => (
173 <li key={suggestion.did} className={styles.suggestionItem}>
174 <button
175 type="button"
176 className={styles.suggestionButton}
177 onClick={() => handleSuggestionClick(suggestion)}
178 >
179 {suggestion.avatar ? (
180 <img
181 src={suggestion.avatar}
182 alt={suggestion.handle}
183 className={styles.avatar}
184 width={28}
185 height={28}
186 />
187 ) : (
188 <div className={styles.avatarPlaceholder}></div>
189 )}
190 <div className={styles.suggestionInfo}>
191 <span className={`${styles.handle} font-medium`}>@{suggestion.handle}</span>
192 </div>
193 </button>
194 </li>
195 ))}
196 </ul>
197 ) : (
198 <div className={styles.noResults}>No results found</div>
199 )}
200 </div>
201 )}
202 </div>
203 );
204}