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