This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

add emoji stats and autocomplete

+580 -68
app/.DS_Store

This is a binary file and will not be displayed.

+49 -4
app/src/app/api/bluesky/stats/route.ts
··· 28 28 'testing.dame.is' 29 29 ]; 30 30 31 + // Define a type for emoji statistics 32 + type EmojiStat = { 33 + emoji: string; 34 + count: number; 35 + }; 36 + 31 37 // If we have Supabase credentials, fetch stats 32 38 if (supabaseUrl && supabaseKey) { 33 39 const supabase = createClient(supabaseUrl, supabaseKey); ··· 108 114 console.log(`Final total count: ${totalCount}`); 109 115 110 116 111 - // 2. Get daily flush counts for the chart 117 + // 2. Get daily flush counts for the chart and emoji data 112 118 const { data: dailyData, error: dailyError } = await supabase 113 119 .from('flushing_records') 114 - .select('created_at, did, handle') 120 + .select('created_at, did, handle, emoji') 115 121 .order('created_at', { ascending: true }); 116 122 117 123 if (dailyError) { ··· 265 271 monthlyActiveFlushers = correctedMAF; 266 272 } 267 273 274 + // 4. Collect emoji statistics 275 + console.log('Collecting emoji statistics...'); 276 + const emojiCounts = new Map<string, number>(); 277 + 278 + // Process all flush records to count emojis 279 + dailyData?.forEach(entry => { 280 + if (entry.emoji) { 281 + // Default to toilet emoji if empty 282 + const emoji = entry.emoji.trim() || '🚽'; 283 + emojiCounts.set(emoji, (emojiCounts.get(emoji) || 0) + 1); 284 + } else { 285 + // Count default toilet emoji if no emoji specified 286 + emojiCounts.set('🚽', (emojiCounts.get('🚽') || 0) + 1); 287 + } 288 + }); 289 + 290 + // Convert to array and sort by count (most popular first) 291 + const emojiStats = Array.from(emojiCounts.entries()) 292 + .map(([emoji, count]): EmojiStat => ({ emoji, count })) 293 + .sort((a, b) => b.count - a.count); 294 + 295 + console.log(`Collected stats for ${emojiStats.length} different emojis`); 296 + 268 297 // Return the data 269 298 return NextResponse.json({ 270 299 totalCount, ··· 274 303 plumberFlushCount, 275 304 totalFlushers, 276 305 monthlyActiveFlushers, 277 - dailyActiveFlushers 306 + dailyActiveFlushers, 307 + emojiStats 278 308 }); 279 309 } else { 280 310 // If no Supabase credentials, return mock data ··· 286 316 plumberFlushCount: 15, 287 317 totalFlushers: 28, 288 318 monthlyActiveFlushers: 18, 289 - dailyActiveFlushers: 5.2 319 + dailyActiveFlushers: 5.2, 320 + emojiStats: generateMockEmojiStats() 290 321 }); 291 322 } 292 323 } catch (error: any) { ··· 336 367 did, 337 368 count: 10 - index 338 369 })); 370 + } 371 + 372 + // Generate mock emoji stats 373 + function generateMockEmojiStats() { 374 + const popularEmojis = [ 375 + '🚽', '💩', '🧻', '💦', '🧼', '🪠', '🚿', '🛁', '🧴', '🌊', 376 + '💨', '🔥', '🚫', '⚠️', '🚪', '🧫', '📱', '🎮', '📖', '😌' 377 + ]; 378 + 379 + return popularEmojis.map((emoji, index) => { 380 + // Generate counts with descending values 381 + const count = Math.floor(Math.random() * 20) + (20 - index); 382 + return { emoji, count }; 383 + }).sort((a, b) => b.count - a.count); // Sort by count in descending order 339 384 }
+126 -2
app/src/app/auth/login/login.module.css
··· 59 59 margin-bottom: 1rem; 60 60 } 61 61 62 + .inputWithSuggestions { 63 + flex: 1; 64 + position: relative; 65 + } 66 + 62 67 .input { 63 - flex: 1; 68 + width: 100%; 64 69 padding: 0.75rem 1rem; 65 70 border: 1px solid var(--input-border); 66 71 border-right: none; ··· 75 80 border-color: var(--input-focus-border); 76 81 } 77 82 83 + /* Suggestions styling */ 84 + .suggestionsContainer { 85 + position: absolute; 86 + top: 100%; 87 + left: 0; 88 + right: 0; 89 + margin-top: 5px; 90 + background-color: var(--card-background); 91 + border: 1px solid var(--tile-border); 92 + border-radius: 8px; 93 + box-shadow: 0 4px 12px var(--shadow-color); 94 + max-height: 300px; 95 + overflow-y: auto; 96 + z-index: 10; 97 + } 98 + 99 + .suggestionsList { 100 + list-style: none; 101 + padding: 0; 102 + margin: 0; 103 + } 104 + 105 + .suggestionItem { 106 + padding: 0; 107 + margin: 0; 108 + border-bottom: 1px solid var(--tile-border); 109 + } 110 + 111 + .suggestionItem:last-child { 112 + border-bottom: none; 113 + } 114 + 115 + .suggestionButton { 116 + display: flex; 117 + align-items: center; 118 + width: 100%; 119 + text-align: left; 120 + padding: 0.75rem 1rem; 121 + background: none; 122 + border: none; 123 + cursor: pointer; 124 + transition: background-color 0.2s; 125 + color: var(--text-color); 126 + gap: 10px; 127 + } 128 + 129 + .suggestionButton:hover { 130 + background-color: var(--button-hover); 131 + /* No transform animation */ 132 + } 133 + 134 + .avatar { 135 + width: 28px; 136 + height: 28px; 137 + border-radius: 50%; 138 + object-fit: cover; 139 + } 140 + 141 + .avatarPlaceholder { 142 + width: 28px; 143 + height: 28px; 144 + border-radius: 50%; 145 + background-color: var(--primary-color); 146 + opacity: 0.3; 147 + } 148 + 149 + .handle { 150 + font-size: 0.9rem; 151 + color: var(--link-color); 152 + } 153 + 154 + .noResults { 155 + padding: 1rem; 156 + text-align: center; 157 + color: var(--timestamp-color); 158 + font-style: italic; 159 + } 160 + 161 + .loadingContainer { 162 + display: flex; 163 + justify-content: center; 164 + padding: 1rem; 165 + gap: 0.3rem; 166 + } 167 + 168 + .loadingDot { 169 + width: 8px; 170 + height: 8px; 171 + border-radius: 50%; 172 + background-color: var(--primary-color); 173 + animation: dotPulse 1.4s infinite ease-in-out; 174 + } 175 + 176 + .loadingDot:nth-child(2) { 177 + animation-delay: 0.2s; 178 + } 179 + 180 + .loadingDot:nth-child(3) { 181 + animation-delay: 0.4s; 182 + } 183 + 184 + @keyframes dotPulse { 185 + 0%, 80%, 100% { 186 + transform: scale(0.8); 187 + opacity: 0.5; 188 + } 189 + 40% { 190 + transform: scale(1.2); 191 + opacity: 1; 192 + } 193 + } 194 + 78 195 .loginButton { 79 196 background-color: var(--primary-color); 80 197 color: white; ··· 134 251 flex-direction: column; 135 252 } 136 253 254 + .inputWithSuggestions { 255 + margin-bottom: 0.5rem; 256 + } 257 + 137 258 .input { 138 259 border-right: 1px solid var(--input-border); 139 260 border-radius: 4px; 140 - margin-bottom: 0.5rem; 141 261 } 142 262 143 263 .loginButton { 144 264 border-radius: 4px; 145 265 padding: 0.75rem; 266 + width: 100%; 267 + } 268 + 269 + .suggestionsContainer { 146 270 width: 100%; 147 271 } 148 272 }
+135 -8
app/src/app/auth/login/page.tsx
··· 11 11 const [error, setError] = useState<string | null>(null); 12 12 const [isLoading, setIsLoading] = useState(false); 13 13 const [handle, setHandle] = useState(''); 14 + const [suggestions, setSuggestions] = useState<Array<{did: string, handle: string, avatar?: string}>>([]); 15 + const [showSuggestions, setShowSuggestions] = useState(false); 16 + const [loadingSuggestions, setLoadingSuggestions] = useState(false); 17 + const suggestionsRef = useRef<HTMLDivElement>(null); 18 + const inputRef = useRef<HTMLInputElement>(null); 19 + const debounceTimerRef = useRef<NodeJS.Timeout | null>(null); 20 + 21 + // Close suggestions when clicking outside 22 + useEffect(() => { 23 + const handleClickOutside = (event: MouseEvent) => { 24 + if ( 25 + suggestionsRef.current && 26 + !suggestionsRef.current.contains(event.target as Node) && 27 + !inputRef.current?.contains(event.target as Node) 28 + ) { 29 + setShowSuggestions(false); 30 + } 31 + }; 14 32 33 + document.addEventListener('mousedown', handleClickOutside); 34 + return () => { 35 + document.removeEventListener('mousedown', handleClickOutside); 36 + }; 37 + }, []); 38 + 39 + // Handle suggestions with debouncing 40 + useEffect(() => { 41 + if (debounceTimerRef.current) { 42 + clearTimeout(debounceTimerRef.current); 43 + } 44 + 45 + // Don't search for very short queries 46 + if (!handle || handle.length < 2) { 47 + setSuggestions([]); 48 + setShowSuggestions(false); 49 + return; 50 + } 51 + 52 + // Set debounce timer 53 + debounceTimerRef.current = setTimeout(async () => { 54 + try { 55 + setLoadingSuggestions(true); 56 + 57 + // Format query - remove @ if present 58 + const searchQuery = handle.trim().startsWith('@') 59 + ? handle.trim().substring(1) 60 + : handle.trim(); 61 + 62 + // Call Bluesky API 63 + const response = await fetch( 64 + `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(searchQuery)}&limit=5` 65 + ); 66 + 67 + if (response.ok) { 68 + const data = await response.json(); 69 + if (data.actors && Array.isArray(data.actors)) { 70 + setSuggestions(data.actors.map((actor: any) => ({ 71 + did: actor.did, 72 + handle: actor.handle, 73 + avatar: actor.avatar 74 + }))); 75 + setShowSuggestions(true); 76 + } 77 + } 78 + } catch (error) { 79 + console.error('Error fetching suggestions:', error); 80 + } finally { 81 + setLoadingSuggestions(false); 82 + } 83 + }, 300); 84 + 85 + return () => { 86 + if (debounceTimerRef.current) { 87 + clearTimeout(debounceTimerRef.current); 88 + } 89 + }; 90 + }, [handle]); 91 + 92 + // Handle selecting a suggestion 93 + const handleSuggestionClick = (selectedHandle: string) => { 94 + setHandle(selectedHandle); 95 + setShowSuggestions(false); 96 + }; 97 + 15 98 // Process login with handle 16 99 const handleLogin = async (e: React.FormEvent) => { 17 100 e.preventDefault(); ··· 163 246 164 247 <form onSubmit={handleLogin}> 165 248 <div className={styles.inputGroup}> 166 - <input 167 - type="text" 168 - value={handle} 169 - onChange={(e) => setHandle(e.target.value)} 170 - placeholder="yourusername.bsky.social" 171 - className={styles.input} 172 - disabled={isLoading} 173 - /> 249 + <div className={styles.inputWithSuggestions}> 250 + <input 251 + ref={inputRef} 252 + type="text" 253 + value={handle} 254 + onChange={(e) => setHandle(e.target.value)} 255 + placeholder="yourusername.bsky.social" 256 + className={styles.input} 257 + disabled={isLoading} 258 + /> 259 + 260 + {/* Suggestions dropdown */} 261 + {showSuggestions && ( 262 + <div className={styles.suggestionsContainer} ref={suggestionsRef}> 263 + {loadingSuggestions ? ( 264 + <div className={styles.loadingContainer}> 265 + <div className={styles.loadingDot}></div> 266 + <div className={styles.loadingDot}></div> 267 + <div className={styles.loadingDot}></div> 268 + </div> 269 + ) : suggestions.length > 0 ? ( 270 + <ul className={styles.suggestionsList}> 271 + {suggestions.map((suggestion) => ( 272 + <li key={suggestion.did} className={styles.suggestionItem}> 273 + <button 274 + type="button" 275 + className={styles.suggestionButton} 276 + onClick={() => handleSuggestionClick(suggestion.handle)} 277 + > 278 + {suggestion.avatar ? ( 279 + <img 280 + src={suggestion.avatar} 281 + alt={suggestion.handle} 282 + className={styles.avatar} 283 + width={28} 284 + height={28} 285 + /> 286 + ) : ( 287 + <div className={styles.avatarPlaceholder}></div> 288 + )} 289 + <span className={styles.handle}>@{suggestion.handle}</span> 290 + </button> 291 + </li> 292 + ))} 293 + </ul> 294 + ) : ( 295 + <div className={styles.noResults}>No results found</div> 296 + )} 297 + </div> 298 + )} 299 + </div> 300 + 174 301 <button 175 302 type="submit" 176 303 className={styles.loginButton}
+3 -3
app/src/app/feed/feed.module.css
··· 135 135 border-radius: 8px; 136 136 padding: 1rem; 137 137 box-shadow: 0 2px 5px var(--shadow-color); 138 - transition: transform 0.2s, box-shadow 0.2s; 138 + /* Removed transition */ 139 139 background-image: repeating-linear-gradient(0deg, var(--tile-border), var(--tile-border) 1px, transparent 1px, transparent 20px); 140 140 } 141 141 142 142 .feedItem:hover { 143 - transform: translateY(-2px); 144 - box-shadow: 0 4px 8px var(--shadow-color); 143 + /* Removed transform and increased box-shadow to prevent movement */ 144 + border-color: var(--primary-color); 145 145 } 146 146 147 147 .feedHeader {
+27 -10
app/src/app/feed/page.tsx
··· 67 67 // Function to load older entries 68 68 const loadOlderEntries = async () => { 69 69 try { 70 - // Save current scroll position 71 - const scrollPosition = window.scrollY; 70 + // Save reference to the "Load older flushes" button element to measure its position 71 + const loadMoreButton = document.getElementById('load-more-button'); 72 + const buttonPosition = loadMoreButton?.getBoundingClientRect(); 72 73 73 74 setLoading(true); 74 75 setError(null); ··· 97 98 const data = await response.json(); 98 99 99 100 if (data.entries && data.entries.length > 0) { 101 + // Get the current document height before adding new content 102 + const oldDocumentHeight = document.body.scrollHeight; 103 + 100 104 // Append the new entries to our existing list 101 - setEntries([...entries, ...data.entries]); 105 + setEntries(prevEntries => [...prevEntries, ...data.entries]); 102 106 103 - // Wait for DOM to update with new entries 104 - setTimeout(() => { 105 - // Restore scroll position after state update and render 106 - window.scrollTo({ 107 - top: scrollPosition, 108 - behavior: 'instant' // Use instant to avoid additional animation 107 + // After state update, maintain position relative to the Load More button 108 + if (buttonPosition) { 109 + // Use requestAnimationFrame to ensure DOM has updated 110 + requestAnimationFrame(() => { 111 + // Get the button's new position 112 + const newButtonElement = document.getElementById('load-more-button'); 113 + 114 + if (newButtonElement) { 115 + // Calculate where to scroll to keep the button in the same viewport position 116 + const newButtonPosition = newButtonElement.getBoundingClientRect(); 117 + const newScrollY = window.scrollY + (newButtonPosition.top - buttonPosition.top); 118 + 119 + // Scroll to the calculated position 120 + window.scrollTo({ 121 + top: newScrollY, 122 + behavior: 'instant' // Use instant to avoid animation 123 + }); 124 + } 109 125 }); 110 - }, 0); 126 + } 111 127 } 112 128 } catch (err: any) { 113 129 console.error('Error fetching older entries:', err); ··· 182 198 183 199 <button 184 200 className={styles.loadMoreButton} 201 + id="load-more-button" 185 202 onClick={(e) => { 186 203 e.preventDefault(); // Prevent default action 187 204 loadOlderEntries();
+3 -4
app/src/app/globals.css
··· 200 200 } 201 201 202 202 main { 203 - min-height: calc(100vh - 60px); 204 203 width: 100%; 205 204 padding-bottom: 2rem; 206 205 max-width: 800px; ··· 254 253 margin: 1rem 0; 255 254 width: 100%; 256 255 box-shadow: 0 2px 5px var(--shadow-color); 257 - transition: transform 0.2s, box-shadow 0.2s; 256 + /* Removed transitions */ 258 257 background-image: repeating-linear-gradient(0deg, var(--tile-border), var(--tile-border) 1px, transparent 1px, transparent 20px); 259 258 } 260 259 261 260 .card:hover { 262 - transform: translateY(-2px); 263 - box-shadow: 0 4px 8px var(--shadow-color); 261 + /* Removed transform and increased box-shadow to prevent movement */ 262 + border-color: var(--primary-color); 264 263 } 265 264 266 265 .form-group {
+14 -7
app/src/app/profile/[handle]/page.tsx
··· 58 58 setProfileLoading(true); 59 59 setProfileError(null); 60 60 61 + // The handle could be either a DID or a regular handle 62 + // Bluesky API's getProfile endpoint accepts both 63 + const actor = handle; 64 + 61 65 // Fetch profile data directly from Bluesky API 62 - const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`); 66 + const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(actor)}`); 63 67 64 68 if (profileResponse.ok) { 65 69 const profileData = await profileResponse.json(); ··· 85 89 setError(null); 86 90 87 91 // Call our API endpoint to get the user's statuses 92 + // The endpoint parameter is named "handle" but it accepts both handles and DIDs 88 93 const response = await fetch(`/api/bluesky/profile?handle=${encodeURIComponent(handle)}`, { 89 94 cache: 'no-store', 90 95 headers: { ··· 161 166 <div className={styles.profileInfo}> 162 167 {profileLoading ? ( 163 168 <div className={styles.profileLoading}> 164 - <h2 className={`${styles.profileTitle} font-bold`}>@{handle}</h2> 169 + <h2 className={`${styles.profileTitle} font-bold`}>{handle.startsWith('did:') ? 'Loading Profile...' : `@${handle}`}</h2> 165 170 <div className={styles.smallLoader}></div> 166 171 </div> 167 172 ) : profileError ? ( 168 173 <div> 169 - <h2 className={`${styles.profileTitle} font-bold`}>@{handle}</h2> 174 + <h2 className={`${styles.profileTitle} font-bold`}>{handle.startsWith('did:') ? 'Profile' : `@${handle}`}</h2> 170 175 <p className={styles.smallError}>Unable to load profile details</p> 171 176 </div> 172 177 ) : ( ··· 174 179 {profileData?.displayName ? ( 175 180 <> 176 181 <h2 className={`${styles.profileTitle} font-bold`}>{profileData.displayName}</h2> 177 - <h3 className={`${styles.profileHandle} font-medium`}>@{handle}</h3> 182 + <h3 className={`${styles.profileHandle} font-medium`}>@{profileData.handle}</h3> 178 183 </> 179 184 ) : ( 180 - <h2 className={`${styles.profileTitle} font-bold`}>@{handle}</h2> 185 + <h2 className={`${styles.profileTitle} font-bold`}>{handle.startsWith('did:') ? 'Profile' : `@${handle}`}</h2> 181 186 )} 182 187 183 188 {profileData?.description && ( ··· 187 192 )} 188 193 189 194 <a 190 - href={`https://bsky.app/profile/${handle}`} 195 + href={profileData ? `https://bsky.app/profile/${profileData.handle}` : `https://bsky.app/profile/${handle}`} 191 196 target="_blank" 192 197 rel="noopener noreferrer" 193 198 className={styles.viewOnBluesky} ··· 239 244 className={styles.shareStatsButton} 240 245 onClick={() => { 241 246 // Open a new window to compose a post on Bluesky 242 - const statsText = `I've made ${totalCount} decentralized ${totalCount === 1 ? 'flush' : 'flushes'}${flushesPerDay > 0 ? ` (averaging ${flushesPerDay} per active day)` : ''} on @flushes.app. Flush with me here: https://flushes.app/profile/${handle}`; 247 + // Use handle from profile data if available, otherwise use the URL parameter 248 + const shareHandle = profileData?.handle || handle; 249 + const statsText = `I've made ${totalCount} decentralized ${totalCount === 1 ? 'flush' : 'flushes'}${flushesPerDay > 0 ? ` (averaging ${flushesPerDay} per active day)` : ''} on @flushes.app. Flush with me here: https://flushes.app/profile/${shareHandle}`; 243 250 window.open(`https://bsky.app/intent/compose?text=${encodeURIComponent(statsText)}`, '_blank'); 244 251 }} 245 252 >
+12 -8
app/src/app/profile/[handle]/profile.module.css
··· 87 87 88 88 .viewOnBluesky:hover { 89 89 text-decoration: underline; 90 - transform: translateX(3px); 90 + /* Removed transform to prevent movement */ 91 91 } 92 92 93 93 /* Stats section and chart */ ··· 167 167 168 168 .shareStatsButton:hover { 169 169 background-color: var(--secondary-color); 170 - transform: translateY(-2px); 171 - box-shadow: 0 4px 8px var(--shadow-color); 170 + /* Removed transform and box-shadow to prevent movement */ 172 171 } 173 172 174 173 .noDataMessage { ··· 211 210 .profileInfo { 212 211 align-items: flex-start; 213 212 } 213 + 214 + .profileText { 215 + margin-left: 0px; 216 + } 214 217 215 218 /* Content alignment for feed items */ 216 219 .contentLeft { 217 220 align-items: center; 221 + display: block; 218 222 } 219 223 } 220 224 ··· 232 236 233 237 .backButton:hover { 234 238 background-color: var(--secondary-color); 235 - transform: translateY(-2px); 236 - box-shadow: 0 4px 8px var(--shadow-color); 239 + /* Removed transform and box-shadow to prevent movement */ 237 240 } 238 241 239 242 .error { ··· 310 313 } 311 314 312 315 .feedItem:hover { 313 - transform: translateY(-2px); 314 - box-shadow: 0 4px 8px var(--shadow-color); 316 + /* Removed transform and increased box-shadow to prevent movement */ 317 + box-shadow: 0 2px 5px var(--shadow-color); 315 318 } 316 319 317 320 @media (max-width: 600px) { ··· 459 462 width: 100%; 460 463 text-align: left; 461 464 margin-top: 0; 462 - padding-left: 0.25rem; 465 + padding-left: 0rem; 463 466 font-size: 0.8rem; 464 467 color: var(--timestamp-color); 465 468 } 466 469 467 470 .emoji { 468 471 font-size: 1.3rem; 472 + display: inline; 469 473 } 470 474 471 475 .text {
+19 -1
app/src/app/stats/page.tsx
··· 15 15 totalFlushers: number; 16 16 monthlyActiveFlushers: number; 17 17 dailyActiveFlushers: number; 18 + emojiStats: { emoji: string; count: number }[]; 18 19 } 19 20 20 21 export default function StatsPage() { ··· 153 154 </div> 154 155 <div className={styles.statCard}> 155 156 <div className={styles.statValue}>{statsData.plumberFlushCount}</div> 156 - <div className={styles.statLabel}>Emergency plumber flushes</div> 157 + <div className={styles.statLabel}>Plumber test flushes</div> 157 158 </div> 158 159 <div className={styles.statCard}> 159 160 <div className={styles.statValue}>{statsData.totalFlushers}</div> ··· 244 245 </div> 245 246 ) : ( 246 247 <p className={styles.noDataMessage}>No leaderboard data available</p> 248 + )} 249 + </section> 250 + 251 + {/* Emoji Statistics */} 252 + <section className={styles.emojiSection}> 253 + <h2>Emoji Usage</h2> 254 + {statsData.emojiStats && statsData.emojiStats.length > 0 ? ( 255 + <div className={styles.emojiGrid}> 256 + {statsData.emojiStats.map((emojiStat, index) => ( 257 + <div key={index} className={styles.emojiCard}> 258 + <div className={styles.emoji}>{emojiStat.emoji}</div> 259 + <div className={styles.emojiCount}>{emojiStat.count}</div> 260 + </div> 261 + ))} 262 + </div> 263 + ) : ( 264 + <p className={styles.noDataMessage}>No emoji data available</p> 247 265 )} 248 266 </section> 249 267
+62 -9
app/src/app/stats/stats.module.css
··· 108 108 109 109 .loginButton:hover { 110 110 background-color: var(--secondary-color); 111 - transform: translateY(-2px); 112 - box-shadow: 0 4px 8px var(--shadow-color); 111 + /* Removed transform and box-shadow to prevent movement */ 113 112 } 114 113 115 114 /* Stats Page Specific Styles */ ··· 219 218 gap: 2rem; 220 219 } 221 220 222 - .overallStats, .chartSection, .leaderboardSection { 221 + .overallStats, .chartSection, .leaderboardSection, .emojiSection { 223 222 background: var(--card-background); 224 223 border-radius: 8px; 225 224 padding: 1.5rem; ··· 227 226 border: 1px solid var(--tile-border); 228 227 } 229 228 230 - .overallStats h2, .chartSection h2, .leaderboardSection h2 { 229 + .overallStats h2, .chartSection h2, .leaderboardSection h2, .emojiSection h2 { 231 230 margin-bottom: 0.5rem; 232 231 font-size: 1.5rem; 233 232 text-align: left; ··· 262 261 } 263 262 264 263 .statCard:hover { 265 - transform: translateY(-5px); 266 - box-shadow: 0 4px 12px var(--shadow-color); 264 + /* Removed transform and box-shadow to prevent movement */ 265 + border-color: var(--primary-color); 267 266 } 268 267 269 268 .statValue { ··· 290 289 291 290 .plumberLink:hover { 292 291 color: #e84142; /* A reddish color for plumber branding */ 293 - transform: scale(1.05); 292 + /* Removed transform to prevent movement */ 294 293 } 295 294 296 295 .plumberLink:after { ··· 411 410 text-align: right; 412 411 } 413 412 413 + /* Emoji Grid */ 414 + .emojiGrid { 415 + display: grid; 416 + grid-template-columns: repeat(5, 1fr); 417 + gap: 1rem; 418 + margin-top: 1.5rem; 419 + } 420 + 421 + .emojiCard { 422 + display: flex; 423 + flex-direction: column; 424 + align-items: center; 425 + justify-content: center; 426 + background-color: var(--input-background); 427 + border-radius: 8px; 428 + padding: 1rem 0.5rem; 429 + border: 1px solid var(--tile-border); 430 + text-align: center; 431 + } 432 + 433 + .emojiCard .emoji { 434 + font-size: 2rem; 435 + margin-bottom: 0.5rem; 436 + } 437 + 438 + .emojiCard .emojiCount { 439 + font-weight: bold; 440 + color: var(--primary-color); 441 + font-size: 1.2rem; 442 + } 443 + 414 444 /* Share Button */ 415 445 .shareSection { 416 446 display: flex; ··· 431 461 432 462 .shareButton:hover { 433 463 background-color: var(--secondary-color); 434 - transform: translateY(-2px); 435 - box-shadow: 0 4px 8px var(--shadow-color); 464 + /* Removed transform and box-shadow to prevent movement */ 436 465 } 437 466 438 467 /* Responsive Adjustments */ ··· 505 534 .user a, .unknownUser { 506 535 font-size: 0.85rem; 507 536 max-width: 100%; 537 + } 538 + 539 + /* Emoji grid responsive */ 540 + .emojiGrid { 541 + grid-template-columns: repeat(3, 1fr); 542 + gap: 0.75rem; 543 + } 544 + 545 + .emojiCard { 546 + padding: 0.75rem 0.25rem; 547 + } 548 + 549 + .emojiCard .emoji { 550 + font-size: 1.75rem; 551 + } 552 + 553 + .emojiCard .emojiCount { 554 + font-size: 1rem; 555 + } 556 + } 557 + 558 + @media (max-width: 400px) { 559 + .emojiGrid { 560 + grid-template-columns: repeat(2, 1fr); 508 561 } 509 562 }
+8 -2
app/src/components/NavigationBar.module.css
··· 95 95 height: 36px; 96 96 display: flex; 97 97 align-items: center; 98 - padding-top: 15px; 98 + padding-top: 0.5rem; 99 99 } 100 100 101 101 .authButton:hover { ··· 221 221 222 222 .secondRow { 223 223 margin-top: 0rem; 224 - gap: .25rem; 224 + gap: 1rem; 225 + flex-direction: column; 226 + } 227 + 228 + .navSearch { 229 + flex: 0; 230 + margin: 0 0; 225 231 } 226 232 }
+28 -2
app/src/components/ProfileSearch.module.css
··· 94 94 cursor: pointer; 95 95 transition: background-color 0.2s; 96 96 color: var(--text-color); 97 + gap: 10px; 97 98 } 98 99 99 100 .suggestionButton:hover { 100 101 background-color: var(--button-hover); 102 + /* No transform on hover */ 103 + } 104 + 105 + .avatar { 106 + width: 28px; 107 + height: 28px; 108 + border-radius: 50%; 109 + object-fit: cover; 110 + flex-shrink: 0; 111 + } 112 + 113 + .avatarPlaceholder { 114 + width: 28px; 115 + height: 28px; 116 + border-radius: 50%; 117 + background-color: var(--primary-color); 118 + opacity: 0.3; 119 + flex-shrink: 0; 101 120 } 102 121 103 122 .suggestionInfo { 104 123 display: flex; 105 124 flex-direction: column; 125 + overflow: hidden; 106 126 } 107 127 108 128 .displayName { 109 129 font-weight: 600; 110 130 font-size: 0.9rem; 111 131 margin-bottom: 0.2rem; 132 + white-space: nowrap; 133 + overflow: hidden; 134 + text-overflow: ellipsis; 112 135 } 113 136 114 137 .handle { 115 - font-size: 0.8rem; 116 - color: var(--timestamp-color); 138 + font-size: 0.9rem; 139 + color: var(--link-color); 140 + white-space: nowrap; 141 + overflow: hidden; 142 + text-overflow: ellipsis; 117 143 } 118 144 119 145 .noResults {
+94 -8
app/src/components/ProfileSearch.tsx
··· 26 26 useEffect(() => { 27 27 const updatePlaceholder = () => { 28 28 if (window.innerWidth <= 480) { 29 - setPlaceholder('@handle'); 29 + setPlaceholder('@handle or DID'); 30 30 } else { 31 - setPlaceholder('Search user @handle'); 31 + setPlaceholder('Search user @handle or did:plc:...'); 32 32 } 33 33 }; 34 34 ··· 60 60 }; 61 61 }, []); 62 62 63 - // Suggestions fetch is disabled for now 63 + // Enable suggestions with debouncing 64 64 useEffect(() => { 65 65 // Clear previous timer if it exists 66 66 if (debounceTimerRef.current) { 67 67 clearTimeout(debounceTimerRef.current); 68 68 } 69 69 70 - // Always hide suggestions 71 - setSuggestions([]); 72 - setShowSuggestions(false); 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 73 113 74 114 return () => { 75 115 if (debounceTimerRef.current) { ··· 91 131 } 92 132 }; 93 133 94 - // Removed handleSuggestionClick as it's no longer needed 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 + }; 95 140 96 141 return ( 97 142 <div className={styles.searchContainer}> ··· 112 157 </svg> 113 158 </button> 114 159 </form> 115 - {/* Suggestions dropdown removed */} 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 + )} 116 202 </div> 117 203 ); 118 204 }