This repository has no description
0

Configure Feed

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

UI tweaks

+335 -82
+71 -1
app/src/app/api/bluesky/feed/route.ts
··· 49 49 const now = Date.now(); 50 50 const url = new URL(request.url); 51 51 const forceRefresh = url.searchParams.get('refresh') === 'true'; 52 + const beforeCursor = url.searchParams.get('before'); 52 53 53 - // Check if cache is still valid and no force refresh is requested 54 + // If we have a 'before' cursor, we're paginating and shouldn't use the cache 55 + if (beforeCursor) { 56 + console.log('Pagination request with cursor:', beforeCursor); 57 + 58 + if (supabaseUrl && supabaseKey) { 59 + const supabase = createClient(supabaseUrl, supabaseKey); 60 + 61 + // Find the record that matches the cursor ID 62 + const { data: cursorRecord, error: cursorError } = await supabase 63 + .from('flushing_records') 64 + .select('created_at') 65 + .eq('id', beforeCursor) 66 + .single(); 67 + 68 + if (cursorError) { 69 + console.error('Error finding cursor record:', cursorError); 70 + // If cursor record not found, just return empty results 71 + return NextResponse.json({ entries: [] }); 72 + } 73 + 74 + // Fetch entries older than the cursor timestamp 75 + const { data: entries, error } = await supabase 76 + .from('flushing_records') 77 + .select(` 78 + id, 79 + uri, 80 + cid, 81 + did, 82 + text, 83 + emoji, 84 + created_at 85 + `) 86 + .lt('created_at', cursorRecord.created_at) // Get entries older than cursor 87 + .order('created_at', { ascending: false }) 88 + .limit(MAX_ENTRIES); 89 + 90 + if (error) { 91 + throw new Error(`Supabase error: ${error.message}`); 92 + } 93 + 94 + // Process and return older entries (skip caching) 95 + const processedEntries = await Promise.all((entries || []).map(async (entry: FlushingRecord) => { 96 + const authorDid = entry.did; 97 + const authorHandle = await resolveDidToHandle(authorDid) || 'unknown'; 98 + 99 + if (containsBannedWords(entry.text)) { 100 + return null; 101 + } 102 + 103 + return { 104 + id: entry.id, 105 + uri: entry.uri, 106 + cid: entry.cid, 107 + authorDid: authorDid, 108 + authorHandle: authorHandle, 109 + text: sanitizeText(entry.text), 110 + emoji: entry.emoji, 111 + createdAt: entry.created_at 112 + } as ProcessedEntry; 113 + })); 114 + 115 + const filteredEntries = processedEntries.filter((entry): entry is ProcessedEntry => entry !== null); 116 + return NextResponse.json({ entries: filteredEntries }); 117 + } else { 118 + // For mock data with pagination, just return empty results 119 + return NextResponse.json({ entries: [] }); 120 + } 121 + } 122 + 123 + // For normal (non-pagination) requests, use the cache if valid 54 124 if (!forceRefresh && now - lastFetchTime < CACHE_TTL && cachedEntries.length > 0) { 55 125 console.log('Returning cached entries'); 56 126 return NextResponse.json({ entries: cachedEntries });
+33
app/src/app/feed/feed.module.css
··· 187 187 border: 1px dashed #ccc; 188 188 } 189 189 190 + .loadMoreButton { 191 + width: 100%; 192 + background-color: #f1f1f1; 193 + color: #444; 194 + border: 1px solid #ddd; 195 + border-radius: 8px; 196 + padding: 1rem; 197 + font-size: 1rem; 198 + font-weight: 500; 199 + cursor: pointer; 200 + margin-top: 1rem; 201 + transition: all 0.2s; 202 + display: flex; 203 + justify-content: center; 204 + align-items: center; 205 + gap: 0.5rem; 206 + } 207 + 208 + .loadMoreButton:hover { 209 + background-color: #e5e5e5; 210 + } 211 + 212 + .loadMoreButton:disabled { 213 + background-color: #f5f5f5; 214 + color: #aaa; 215 + cursor: not-allowed; 216 + } 217 + 218 + .loadMoreButton svg { 219 + width: 16px; 220 + height: 16px; 221 + } 222 + 190 223 .createButton { 191 224 display: inline-block; 192 225 margin-top: 1rem;
+77 -20
app/src/app/feed/page.tsx
··· 63 63 setLoading(false); 64 64 } 65 65 }; 66 + 67 + // Function to load older entries 68 + const loadOlderEntries = async () => { 69 + try { 70 + setLoading(true); 71 + setError(null); 72 + 73 + // Get the oldest entry we currently have 74 + const oldestEntry = entries[entries.length - 1]; 75 + if (!oldestEntry) { 76 + return; // No entries to use as cursor 77 + } 78 + 79 + // Use the oldest entry's ID as the cursor 80 + const url = `/api/bluesky/feed?before=${oldestEntry.id}`; 81 + 82 + const response = await fetch(url, { 83 + cache: 'no-store', 84 + headers: { 85 + 'Cache-Control': 'no-cache', 86 + 'Pragma': 'no-cache' 87 + } 88 + }); 89 + 90 + if (!response.ok) { 91 + throw new Error(`Failed to fetch older entries: ${response.status}`); 92 + } 93 + 94 + const data = await response.json(); 95 + 96 + if (data.entries && data.entries.length > 0) { 97 + // Append the new entries to our existing list 98 + setEntries([...entries, ...data.entries]); 99 + } 100 + } catch (err: any) { 101 + console.error('Error fetching older entries:', err); 102 + setError(err.message || 'Failed to load older entries'); 103 + } finally { 104 + setLoading(false); 105 + } 106 + }; 66 107 67 108 // No longer needed - using formatRelativeTime from time-utils 68 109 ··· 104 145 105 146 <div className={styles.feedList}> 106 147 {entries.length > 0 ? ( 107 - entries.map((entry) => ( 108 - <div key={entry.id} className={styles.feedItem}> 109 - <div className={styles.feedHeader}> 110 - <a 111 - href={`https://bsky.app/profile/${entry.authorHandle}`} 112 - target="_blank" 113 - rel="noopener noreferrer" 114 - className={styles.authorLink} 115 - > 116 - @{entry.authorHandle} 117 - </a> 118 - <span className={styles.timestamp}> 119 - {formatRelativeTime(entry.createdAt)} 120 - </span> 121 - </div> 122 - <div className={styles.content}> 123 - <span className={styles.emoji}>{entry.emoji}</span> 124 - <span className={styles.text}>{entry.text.length > 60 ? `${entry.text.substring(0, 60)}...` : entry.text}</span> 148 + <> 149 + {entries.map((entry) => ( 150 + <div key={entry.id} className={styles.feedItem}> 151 + <div className={styles.feedHeader}> 152 + <a 153 + href={`https://bsky.app/profile/${entry.authorHandle}`} 154 + target="_blank" 155 + rel="noopener noreferrer" 156 + className={styles.authorLink} 157 + > 158 + @{entry.authorHandle} 159 + </a> 160 + <span className={styles.timestamp}> 161 + {formatRelativeTime(entry.createdAt)} 162 + </span> 163 + </div> 164 + <div className={styles.content}> 165 + <span className={styles.emoji}>{entry.emoji}</span> 166 + <span className={styles.text}>{entry.text.length > 60 ? `${entry.text.substring(0, 60)}...` : entry.text}</span> 167 + </div> 125 168 </div> 126 - </div> 127 - )) 169 + ))} 170 + 171 + <button 172 + className={styles.loadMoreButton} 173 + onClick={loadOlderEntries} 174 + disabled={loading} 175 + > 176 + {loading ? 'Loading...' : 'Load older flushes'} 177 + {!loading && ( 178 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 179 + <polyline points="7 13 12 18 17 13"></polyline> 180 + <polyline points="7 6 12 11 17 6"></polyline> 181 + </svg> 182 + )} 183 + </button> 184 + </> 128 185 ) : !loading ? ( 129 186 <div className={styles.emptyState}> 130 187 <p>No entries found. Be the first to share your status!</p>
+55 -19
app/src/app/page.module.css
··· 212 212 } 213 213 214 214 .emojiNote { 215 + display: none; /* Hide since we don't need to scroll anymore */ 215 216 margin: 0 0 0.5rem 0; 216 217 font-size: 0.85rem; 217 218 color: #666; ··· 280 281 281 282 .emojiGrid { 282 283 display: grid; 283 - grid-template-columns: repeat(8, 1fr); 284 + grid-template-columns: repeat(auto-fill, minmax(2.2rem, 1fr)); 284 285 gap: 0.5rem; 285 - max-height: 300px; 286 - overflow-y: auto; 287 - padding: 0.5rem; 286 + padding: 0.8rem; 288 287 border: 1px solid #eee; 289 288 border-radius: 8px; 290 289 background-color: #fcfcfc; 290 + max-height: none; /* Remove height restriction */ 291 + overflow-y: visible; /* No need for scrolling */ 291 292 } 292 293 293 294 @media (max-width: 600px) { 294 295 .emojiGrid { 295 - grid-template-columns: repeat(6, 1fr); 296 - max-height: 220px; 297 - overflow-y: auto; 298 - padding: 0.5rem; 299 - gap: 0.6rem; 300 - -webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */ 296 + grid-template-columns: repeat(auto-fill, minmax(2rem, 1fr)); 297 + gap: 0.4rem; 298 + padding: 0.6rem; 301 299 } 302 300 } 303 301 304 302 @media (max-width: 400px) { 305 303 .emojiGrid { 306 - grid-template-columns: repeat(5, 1fr); 307 - max-height: 240px; 304 + grid-template-columns: repeat(auto-fill, minmax(1.8rem, 1fr)); 305 + gap: 0.3rem; 306 + padding: 0.5rem; 308 307 } 309 308 } 310 309 ··· 312 311 background: #f5f5f5; 313 312 border: 1px solid #ddd; 314 313 border-radius: 4px; 315 - font-size: 1.5rem; 314 + font-size: 1.3rem; 316 315 aspect-ratio: 1/1; 317 316 display: flex; 318 317 align-items: center; 319 318 justify-content: center; 320 319 cursor: pointer; 321 320 transition: all 0.2s; 322 - padding: 8px; 321 + padding: 0.5rem; 322 + min-width: 2rem; 323 + min-height: 2rem; 323 324 } 324 325 325 326 @media (max-width: 600px) { 326 327 .emojiButton { 327 - font-size: 1.5rem; 328 - padding: 6px; 328 + font-size: 1.2rem; 329 + padding: 0.4rem; 330 + min-width: 1.8rem; 331 + min-height: 1.8rem; 329 332 } 330 333 } 331 334 ··· 441 444 margin: 0; 442 445 display: flex; 443 446 flex-direction: column; 444 - gap: 0.5rem; 445 447 } 446 448 447 449 .statsLink { 448 - display: inline-block; 450 + display: block; 449 451 color: var(--primary-color); 450 452 font-weight: 500; 451 453 text-decoration: none; 452 454 transition: color 0.2s; 453 - margin-top: 0.25rem; 455 + margin-top: 0.5rem; 456 + margin-bottom: 1rem; 454 457 } 455 458 456 459 .statsLink:hover { ··· 659 662 background-color: #f9f9f9; 660 663 border-radius: 8px; 661 664 border: 1px dashed #ccc; 665 + } 666 + 667 + .loadMoreButton { 668 + width: 100%; 669 + background-color: #f1f1f1; 670 + color: #444; 671 + border: 1px solid #ddd; 672 + border-radius: 8px; 673 + padding: 1rem; 674 + font-size: 1rem; 675 + font-weight: 500; 676 + cursor: pointer; 677 + margin-top: 1rem; 678 + transition: all 0.2s; 679 + display: flex; 680 + justify-content: center; 681 + align-items: center; 682 + gap: 0.5rem; 683 + } 684 + 685 + .loadMoreButton:hover { 686 + background-color: #e5e5e5; 687 + } 688 + 689 + .loadMoreButton:disabled { 690 + background-color: #f5f5f5; 691 + color: #aaa; 692 + cursor: not-allowed; 693 + } 694 + 695 + .loadMoreButton svg { 696 + width: 16px; 697 + height: 16px; 662 698 } 663 699 664 700 .error {
+96 -39
app/src/app/page.tsx
··· 234 234 setLoading(false); 235 235 } 236 236 }; 237 + 238 + // Function to load older entries 239 + const loadOlderEntries = async () => { 240 + try { 241 + setLoading(true); 242 + setError(null); 243 + 244 + // Get the oldest entry we currently have 245 + const oldestEntry = entries[entries.length - 1]; 246 + if (!oldestEntry) { 247 + return; // No entries to use as cursor 248 + } 249 + 250 + // Use the oldest entry's ID as the cursor 251 + const url = `/api/bluesky/feed?before=${oldestEntry.id}`; 252 + 253 + const response = await fetch(url, { 254 + cache: 'no-store', 255 + headers: { 256 + 'Cache-Control': 'no-cache', 257 + 'Pragma': 'no-cache' 258 + } 259 + }); 260 + 261 + if (!response.ok) { 262 + throw new Error(`Failed to fetch older entries: ${response.status}`); 263 + } 264 + 265 + const data = await response.json(); 266 + 267 + if (data.entries && data.entries.length > 0) { 268 + // Append the new entries to our existing list 269 + setEntries([...entries, ...data.entries]); 270 + } 271 + } catch (err: any) { 272 + console.error('Error fetching older entries:', err); 273 + setError(err.message || 'Failed to load older entries'); 274 + } finally { 275 + setLoading(false); 276 + } 277 + }; 237 278 238 279 // Function to handle logout 239 280 const handleLogout = () => { ··· 290 331 <path d="M19 9L12 16L5 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> 291 332 </svg> 292 333 </button> 334 + <Link href="/stats" className={styles.statsLink}>View Plumbing Stats 🪠</Link> 293 335 294 336 {/* Collapsible status update form */} 295 337 <div className={`${styles.statusUpdateContainer} ${statusOpen ? styles.statusUpdateOpen : ''}`}> ··· 300 342 <form onSubmit={handleSubmit} className={styles.form}> 301 343 <div className={styles.formGroup}> 302 344 <label>Select an emoji for your status</label> 303 - <p className={styles.emojiNote}>Scroll to see all options</p> 304 345 <div className={styles.emojiGrid}> 305 346 {EMOJIS.map((emoji) => ( 306 347 <button ··· 359 400 <div className={styles.feedHeaderLeft}> 360 401 <h2>Recent flushes</h2> 361 402 <p className={styles.feedSubheader}> 362 - Click on a username to see their flushing profile. 363 - <Link href="/stats" className={styles.statsLink}>View Plumbing Stats 🪠</Link> 403 + Click on a username to see their flushing profile. 364 404 </p> 365 405 </div> 366 406 <button ··· 385 425 // Filter first to determine if we have any valid entries 386 426 (() => { 387 427 const validEntries = entries.filter(entry => isAllowedEmoji(entry.emoji)); 388 - return validEntries.length > 0 ? 389 - validEntries.map((entry) => ( 390 - <div 391 - key={entry.id} 392 - className={`${styles.feedItem} ${newEntryIds.has(entry.id) ? styles.newFeedItem : ''}`} 393 - > 394 - <div className={styles.content}> 395 - <div className={styles.contentLeft}> 396 - <span className={styles.emoji}>{entry.emoji}</span> 397 - <Link 398 - href={`/profile/${entry.authorHandle}`} 399 - className={styles.authorLink} 400 - > 401 - @{entry.authorHandle} 402 - </Link> 403 - <span className={styles.text}> 404 - {entry.text ? ( 405 - entry.authorHandle && entry.authorHandle.endsWith('.is') ? 406 - // For handles ending with .is, remove the "is" prefix if it exists 407 - (sanitizeText(entry.text).toLowerCase().startsWith('is ') ? 408 - (entry.text.length > 63 ? `${sanitizeText(entry.text.substring(3, 63))}...` : sanitizeText(entry.text.substring(3))) : 409 - (entry.text.length > 60 ? `${sanitizeText(entry.text.substring(0, 60))}...` : sanitizeText(entry.text)) 410 - ) : 411 - // For regular handles, display normal text 412 - (entry.text.length > 60 ? `${sanitizeText(entry.text.substring(0, 60))}...` : sanitizeText(entry.text)) 413 - ) : ( 414 - entry.authorHandle && entry.authorHandle.endsWith('.is') ? 415 - 'flushing' : 'is flushing' 416 - )} 417 - </span> 428 + return validEntries.length > 0 ? ( 429 + <> 430 + {validEntries.map((entry) => ( 431 + <div 432 + key={entry.id} 433 + className={`${styles.feedItem} ${newEntryIds.has(entry.id) ? styles.newFeedItem : ''}`} 434 + > 435 + <div className={styles.content}> 436 + <div className={styles.contentLeft}> 437 + <span className={styles.emoji}>{entry.emoji}</span> 438 + <Link 439 + href={`/profile/${entry.authorHandle}`} 440 + className={styles.authorLink} 441 + > 442 + @{entry.authorHandle} 443 + </Link> 444 + <span className={styles.text}> 445 + {entry.text ? ( 446 + entry.authorHandle && entry.authorHandle.endsWith('.is') ? 447 + // For handles ending with .is, remove the "is" prefix if it exists 448 + (sanitizeText(entry.text).toLowerCase().startsWith('is ') ? 449 + (entry.text.length > 63 ? `${sanitizeText(entry.text.substring(3, 63))}...` : sanitizeText(entry.text.substring(3))) : 450 + (entry.text.length > 60 ? `${sanitizeText(entry.text.substring(0, 60))}...` : sanitizeText(entry.text)) 451 + ) : 452 + // For regular handles, display normal text 453 + (entry.text.length > 60 ? `${sanitizeText(entry.text.substring(0, 60))}...` : sanitizeText(entry.text)) 454 + ) : ( 455 + entry.authorHandle && entry.authorHandle.endsWith('.is') ? 456 + 'flushing' : 'is flushing' 457 + )} 458 + </span> 459 + </div> 460 + <span className={styles.timestamp}> 461 + {formatRelativeTime(entry.createdAt)} 462 + </span> 463 + </div> 418 464 </div> 419 - <span className={styles.timestamp}> 420 - {formatRelativeTime(entry.createdAt)} 421 - </span> 422 - </div> 423 - </div> 424 - )) : ( 465 + ))} 466 + 467 + <button 468 + className={styles.loadMoreButton} 469 + onClick={loadOlderEntries} 470 + disabled={loading} 471 + > 472 + {loading ? 'Loading...' : 'Load older flushes'} 473 + {!loading && ( 474 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 475 + <polyline points="7 13 12 18 17 13"></polyline> 476 + <polyline points="7 6 12 11 17 6"></polyline> 477 + </svg> 478 + )} 479 + </button> 480 + </> 481 + ) : ( 425 482 <div className={styles.emptyState}> 426 483 <p>No valid entries found. Login and be the first to share your status!</p> 427 484 </div>
+3 -3
app/src/app/stats/stats.module.css
··· 114 114 115 115 /* Stats Page Specific Styles */ 116 116 .statsHeader { 117 - text-align: center; 117 + text-align: left; 118 118 margin-bottom: 2rem; 119 119 } 120 120 ··· 132 132 133 133 .controls { 134 134 display: flex; 135 - justify-content: center; 135 + justify-content: flex-start; 136 136 gap: 1rem; 137 137 margin-bottom: 2rem; 138 138 } ··· 228 228 color: var(--primary-color); 229 229 margin-bottom: 1.5rem; 230 230 font-size: 1.5rem; 231 - text-align: center; 231 + text-align: left; 232 232 } 233 233 234 234 /* Stats Grid */