alpha
Login
or
Join now
atpota.to
/
cred.blue
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
This repository has no description
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
add resources pages and infra
author
damedotblog
date
1 year ago
(Feb 27, 2025, 4:14 PM -0500)
commit
f9e0b751
f9e0b751bbacc8dd8b994cece598f4543c59e5ae
parent
563dadf7
563dadf720eb562e703aeb409a40542d88862c72
+1718
-1611
8 changed files
Expand all
Collapse all
Unified
Split
src
App.jsx
components
Admin
ResourcesManager.css
ResourcesManager.js
Resources
ResourceAdmin.js
ResourceSubmission.js
Resources.css
Resources.js
services
supabaseClient.js
+4
src/App.jsx
Reviewed
···
10
10
import Supporter from './components/Supporter/Supporter';
11
11
import Shortcut from './components/Shortcut/Shortcut';
12
12
import Resources from './components/Resources/Resources';
13
13
+
import ResourcesManager from './components/Admin/ResourcesManager';
14
14
+
import ResourceSubmission from './components/Resources/ResourceSubmission';
13
15
import ScoringMethodology from './components/ScoringMethodology/ScoringMethodology';
14
16
import Terms from './components/PrivacyTerms/Terms';
15
17
import Privacy from './components/PrivacyTerms/Privacy';
···
39
41
<Route path="/supporter" element={<Supporter />} />
40
42
<Route path="/leaderboard" element={<Leaderboard />} />
41
43
<Route path="/resources" element={<Resources />} />
44
44
+
<Route path="/resources/submit" element={<ResourceSubmission />} />
45
45
+
<Route path="/admin/resources" element={<ResourcesManager />} />
42
46
<Route path="/shortcut" element={<Shortcut />} />
43
47
<Route path="/zen" element={<ZenPage />} />
44
48
<Route path="/methodology" element={<ScoringMethodology />} />
+456
src/components/Admin/ResourcesManager.css
Reviewed
···
1
1
+
/* src/components/Admin/ResourcesManager.css */
2
2
+
3
3
+
.admin-resources-manager {
4
4
+
padding: 20px;
5
5
+
max-width: 1400px;
6
6
+
margin: 0 auto;
7
7
+
}
8
8
+
9
9
+
.admin-resources-manager h1 {
10
10
+
margin-bottom: 24px;
11
11
+
color: #333;
12
12
+
}
13
13
+
14
14
+
.admin-error-alert {
15
15
+
background-color: #ffebee;
16
16
+
color: #c62828;
17
17
+
padding: 12px 16px;
18
18
+
border-radius: 4px;
19
19
+
margin-bottom: 20px;
20
20
+
border-left: 4px solid #c62828;
21
21
+
}
22
22
+
23
23
+
/* Tabs */
24
24
+
.admin-tabs {
25
25
+
display: flex;
26
26
+
margin-bottom: 24px;
27
27
+
border-bottom: 1px solid #e0e0e0;
28
28
+
}
29
29
+
30
30
+
.admin-tabs button {
31
31
+
padding: 12px 24px;
32
32
+
font-size: 16px;
33
33
+
background: none;
34
34
+
border: none;
35
35
+
cursor: pointer;
36
36
+
font-weight: 500;
37
37
+
color: #666;
38
38
+
position: relative;
39
39
+
}
40
40
+
41
41
+
.admin-tabs button.active {
42
42
+
color: #007aff;
43
43
+
font-weight: 600;
44
44
+
}
45
45
+
46
46
+
.admin-tabs button.active::after {
47
47
+
content: '';
48
48
+
position: absolute;
49
49
+
bottom: -1px;
50
50
+
left: 0;
51
51
+
right: 0;
52
52
+
height: 3px;
53
53
+
background-color: #007aff;
54
54
+
}
55
55
+
56
56
+
.badge {
57
57
+
display: inline-block;
58
58
+
background-color: #007aff;
59
59
+
color: white;
60
60
+
font-size: 12px;
61
61
+
border-radius: 12px;
62
62
+
padding: 2px 8px;
63
63
+
margin-left: 8px;
64
64
+
}
65
65
+
66
66
+
/* Toolbar */
67
67
+
.admin-toolbar {
68
68
+
display: flex;
69
69
+
justify-content: space-between;
70
70
+
align-items: center;
71
71
+
margin-bottom: 20px;
72
72
+
flex-wrap: wrap;
73
73
+
gap: 10px;
74
74
+
}
75
75
+
76
76
+
.admin-search {
77
77
+
flex: 1;
78
78
+
max-width: 300px;
79
79
+
}
80
80
+
81
81
+
.admin-search input {
82
82
+
width: 100%;
83
83
+
padding: 10px 12px;
84
84
+
border: 1px solid #ddd;
85
85
+
border-radius: 4px;
86
86
+
font-size: 14px;
87
87
+
}
88
88
+
89
89
+
.admin-filter select {
90
90
+
padding: 10px 12px;
91
91
+
border: 1px solid #ddd;
92
92
+
border-radius: 4px;
93
93
+
font-size: 14px;
94
94
+
min-width: 200px;
95
95
+
}
96
96
+
97
97
+
.admin-add-button {
98
98
+
background-color: #007aff;
99
99
+
color: white;
100
100
+
border: none;
101
101
+
border-radius: 4px;
102
102
+
padding: 10px 16px;
103
103
+
font-size: 14px;
104
104
+
font-weight: 500;
105
105
+
cursor: pointer;
106
106
+
transition: background-color 0.2s;
107
107
+
}
108
108
+
109
109
+
.admin-add-button:hover {
110
110
+
background-color: #0062cc;
111
111
+
}
112
112
+
113
113
+
/* Loading state */
114
114
+
.admin-loading {
115
115
+
text-align: center;
116
116
+
padding: 40px;
117
117
+
color: #666;
118
118
+
font-size: 16px;
119
119
+
}
120
120
+
121
121
+
/* Table */
122
122
+
.admin-table {
123
123
+
width: 100%;
124
124
+
border-collapse: collapse;
125
125
+
font-size: 14px;
126
126
+
}
127
127
+
128
128
+
.admin-table th {
129
129
+
background-color: #f5f5f5;
130
130
+
color: #333;
131
131
+
text-align: left;
132
132
+
padding: 12px 16px;
133
133
+
font-weight: 600;
134
134
+
border-bottom: 2px solid #e0e0e0;
135
135
+
}
136
136
+
137
137
+
.admin-table th.sortable {
138
138
+
cursor: pointer;
139
139
+
}
140
140
+
141
141
+
.admin-table th.sortable:hover {
142
142
+
background-color: #e0e0e0;
143
143
+
}
144
144
+
145
145
+
.admin-table td {
146
146
+
padding: 12px 16px;
147
147
+
border-bottom: 1px solid #e0e0e0;
148
148
+
vertical-align: middle;
149
149
+
}
150
150
+
151
151
+
.admin-table tr:hover {
152
152
+
background-color: #f9f9f9;
153
153
+
}
154
154
+
155
155
+
.admin-table a {
156
156
+
color: #007aff;
157
157
+
text-decoration: none;
158
158
+
}
159
159
+
160
160
+
.admin-table a:hover {
161
161
+
text-decoration: underline;
162
162
+
}
163
163
+
164
164
+
.admin-table .subcategory {
165
165
+
color: #666;
166
166
+
font-size: 13px;
167
167
+
}
168
168
+
169
169
+
/* Highlight new resources */
170
170
+
.admin-table tr.new-resource {
171
171
+
background-color: #f0f8ff;
172
172
+
}
173
173
+
174
174
+
.admin-table tr.new-resource:hover {
175
175
+
background-color: #e3f2fd;
176
176
+
}
177
177
+
178
178
+
/* Action buttons */
179
179
+
.action-buttons {
180
180
+
display: flex;
181
181
+
gap: 8px;
182
182
+
flex-wrap: wrap;
183
183
+
}
184
184
+
185
185
+
.action-buttons button {
186
186
+
padding: 6px 12px;
187
187
+
border: none;
188
188
+
border-radius: 4px;
189
189
+
font-size: 13px;
190
190
+
cursor: pointer;
191
191
+
font-weight: 500;
192
192
+
}
193
193
+
194
194
+
.edit-button {
195
195
+
background-color: #f0f0f0;
196
196
+
color: #333;
197
197
+
}
198
198
+
199
199
+
.edit-button:hover {
200
200
+
background-color: #e0e0e0;
201
201
+
}
202
202
+
203
203
+
.delete-button {
204
204
+
background-color: #ffebee;
205
205
+
color: #c62828;
206
206
+
}
207
207
+
208
208
+
.delete-button:hover {
209
209
+
background-color: #ffcdd2;
210
210
+
}
211
211
+
212
212
+
.approve-button {
213
213
+
background-color: #e8f5e9;
214
214
+
color: #2e7d32;
215
215
+
}
216
216
+
217
217
+
.approve-button:hover {
218
218
+
background-color: #c8e6c9;
219
219
+
}
220
220
+
221
221
+
.reject-button {
222
222
+
background-color: #ffebee;
223
223
+
color: #c62828;
224
224
+
}
225
225
+
226
226
+
.reject-button:hover {
227
227
+
background-color: #ffcdd2;
228
228
+
}
229
229
+
230
230
+
.edit-approve-button {
231
231
+
background-color: #e3f2fd;
232
232
+
color: #0277bd;
233
233
+
}
234
234
+
235
235
+
.edit-approve-button:hover {
236
236
+
background-color: #bbdefb;
237
237
+
}
238
238
+
239
239
+
/* No results */
240
240
+
.admin-no-results {
241
241
+
padding: 40px;
242
242
+
text-align: center;
243
243
+
color: #666;
244
244
+
background-color: #f9f9f9;
245
245
+
border-radius: 4px;
246
246
+
}
247
247
+
248
248
+
/* Quality stars styling */
249
249
+
.quality-star {
250
250
+
color: #ddd;
251
251
+
font-size: 16px;
252
252
+
}
253
253
+
254
254
+
.quality-star.filled {
255
255
+
color: #ffc107;
256
256
+
}
257
257
+
258
258
+
/* Modal for editing/creating resources */
259
259
+
.admin-modal {
260
260
+
position: fixed;
261
261
+
top: 0;
262
262
+
left: 0;
263
263
+
right: 0;
264
264
+
bottom: 0;
265
265
+
background-color: rgba(0, 0, 0, 0.5);
266
266
+
display: flex;
267
267
+
justify-content: center;
268
268
+
align-items: center;
269
269
+
z-index: 1000;
270
270
+
}
271
271
+
272
272
+
.admin-modal-content {
273
273
+
background-color: white;
274
274
+
border-radius: 8px;
275
275
+
padding: 24px;
276
276
+
width: 90%;
277
277
+
max-width: 700px;
278
278
+
max-height: 90vh;
279
279
+
overflow-y: auto;
280
280
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
281
281
+
}
282
282
+
283
283
+
.admin-modal-content h2 {
284
284
+
margin-top: 0;
285
285
+
margin-bottom: 24px;
286
286
+
color: #333;
287
287
+
}
288
288
+
289
289
+
/* Form styling */
290
290
+
.form-group {
291
291
+
margin-bottom: 16px;
292
292
+
}
293
293
+
294
294
+
.form-group label {
295
295
+
display: block;
296
296
+
margin-bottom: 8px;
297
297
+
font-weight: 500;
298
298
+
color: #333;
299
299
+
}
300
300
+
301
301
+
.form-group input[type="text"],
302
302
+
.form-group input[type="url"],
303
303
+
.form-group input[type="email"],
304
304
+
.form-group select,
305
305
+
.form-group textarea {
306
306
+
width: 100%;
307
307
+
padding: 10px 12px;
308
308
+
border: 1px solid #ddd;
309
309
+
border-radius: 4px;
310
310
+
font-size: 14px;
311
311
+
}
312
312
+
313
313
+
.form-group textarea {
314
314
+
resize: vertical;
315
315
+
min-height: 80px;
316
316
+
}
317
317
+
318
318
+
.form-group input:disabled {
319
319
+
background-color: #f5f5f5;
320
320
+
cursor: not-allowed;
321
321
+
}
322
322
+
323
323
+
.form-group small {
324
324
+
display: block;
325
325
+
margin-top: 4px;
326
326
+
color: #666;
327
327
+
font-size: 12px;
328
328
+
}
329
329
+
330
330
+
.form-row {
331
331
+
display: flex;
332
332
+
gap: 16px;
333
333
+
margin-bottom: 16px;
334
334
+
}
335
335
+
336
336
+
.form-row .form-group {
337
337
+
flex: 1;
338
338
+
margin-bottom: 0;
339
339
+
}
340
340
+
341
341
+
.checkbox-group {
342
342
+
display: flex;
343
343
+
align-items: center;
344
344
+
}
345
345
+
346
346
+
.checkbox-group label {
347
347
+
display: flex;
348
348
+
align-items: center;
349
349
+
cursor: pointer;
350
350
+
margin-bottom: 0;
351
351
+
}
352
352
+
353
353
+
.checkbox-group input[type="checkbox"] {
354
354
+
margin-right: 8px;
355
355
+
width: 18px;
356
356
+
height: 18px;
357
357
+
}
358
358
+
359
359
+
.submission-info {
360
360
+
background-color: #f5f5f5;
361
361
+
padding: 12px;
362
362
+
border-radius: 4px;
363
363
+
margin-bottom: 0;
364
364
+
font-size: 14px;
365
365
+
line-height: 1.6;
366
366
+
}
367
367
+
368
368
+
.form-buttons {
369
369
+
display: flex;
370
370
+
justify-content: flex-end;
371
371
+
gap: 12px;
372
372
+
margin-top: 24px;
373
373
+
}
374
374
+
375
375
+
.save-button {
376
376
+
background-color: #007aff;
377
377
+
color: white;
378
378
+
border: none;
379
379
+
border-radius: 4px;
380
380
+
padding: 10px 16px;
381
381
+
font-size: 14px;
382
382
+
font-weight: 500;
383
383
+
cursor: pointer;
384
384
+
transition: background-color 0.2s;
385
385
+
}
386
386
+
387
387
+
.save-button:hover {
388
388
+
background-color: #0062cc;
389
389
+
}
390
390
+
391
391
+
.cancel-button {
392
392
+
background-color: #f0f0f0;
393
393
+
color: #333;
394
394
+
border: none;
395
395
+
border-radius: 4px;
396
396
+
padding: 10px 16px;
397
397
+
font-size: 14px;
398
398
+
font-weight: 500;
399
399
+
cursor: pointer;
400
400
+
transition: background-color 0.2s;
401
401
+
}
402
402
+
403
403
+
.cancel-button:hover {
404
404
+
background-color: #e0e0e0;
405
405
+
}
406
406
+
407
407
+
/* Responsive adjustments */
408
408
+
@media (max-width: 768px) {
409
409
+
.admin-toolbar {
410
410
+
flex-direction: column;
411
411
+
align-items: stretch;
412
412
+
}
413
413
+
414
414
+
.admin-search {
415
415
+
max-width: none;
416
416
+
}
417
417
+
418
418
+
.form-row {
419
419
+
flex-direction: column;
420
420
+
gap: 16px;
421
421
+
}
422
422
+
423
423
+
.form-buttons {
424
424
+
flex-direction: column;
425
425
+
}
426
426
+
427
427
+
.form-buttons button {
428
428
+
width: 100%;
429
429
+
}
430
430
+
431
431
+
.action-buttons {
432
432
+
flex-direction: column;
433
433
+
}
434
434
+
435
435
+
.action-buttons button {
436
436
+
width: 100%;
437
437
+
}
438
438
+
439
439
+
.admin-table {
440
440
+
font-size: 13px;
441
441
+
}
442
442
+
443
443
+
.admin-table th,
444
444
+
.admin-table td {
445
445
+
padding: 8px;
446
446
+
}
447
447
+
}
448
448
+
449
449
+
/* Optional: Add responsive table for mobile */
450
450
+
@media (max-width: 576px) {
451
451
+
.admin-table {
452
452
+
display: block;
453
453
+
overflow-x: auto;
454
454
+
white-space: nowrap;
455
455
+
}
456
456
+
}
+667
src/components/Admin/ResourcesManager.js
Reviewed
···
1
1
+
// src/components/Admin/ResourcesManager.jsx
2
2
+
import React, { useState, useEffect } from 'react';
3
3
+
import { supabase } from '../../lib/supabaseClient';
4
4
+
import './ResourcesManager.css';
5
5
+
6
6
+
const ResourcesManager = () => {
7
7
+
const [activeTab, setActiveTab] = useState('resources');
8
8
+
const [resources, setResources] = useState([]);
9
9
+
const [submissions, setSubmissions] = useState([]);
10
10
+
const [categories, setCategories] = useState([]);
11
11
+
const [isLoading, setIsLoading] = useState(true);
12
12
+
const [error, setError] = useState(null);
13
13
+
14
14
+
// For editing a resource
15
15
+
const [editingResource, setEditingResource] = useState(null);
16
16
+
const [formData, setFormData] = useState({
17
17
+
name: '',
18
18
+
url: '',
19
19
+
description: '',
20
20
+
domain: '',
21
21
+
category_id: '',
22
22
+
subcategory: '',
23
23
+
quality: 3,
24
24
+
featured: false
25
25
+
});
26
26
+
27
27
+
// For filtering and sorting
28
28
+
const [searchTerm, setSearchTerm] = useState('');
29
29
+
const [categoryFilter, setCategoryFilter] = useState('');
30
30
+
const [sortField, setSortField] = useState('created_at');
31
31
+
const [sortDirection, setSortDirection] = useState('desc');
32
32
+
33
33
+
// Load data based on active tab
34
34
+
useEffect(() => {
35
35
+
fetchData();
36
36
+
}, [activeTab, sortField, sortDirection]);
37
37
+
38
38
+
// Load categories
39
39
+
useEffect(() => {
40
40
+
async function fetchCategories() {
41
41
+
try {
42
42
+
const { data, error } = await supabase
43
43
+
.from('categories')
44
44
+
.select('*')
45
45
+
.order('position');
46
46
+
47
47
+
if (error) throw error;
48
48
+
setCategories(data || []);
49
49
+
} catch (error) {
50
50
+
console.error('Error fetching categories:', error);
51
51
+
setError('Failed to load categories. Please refresh the page.');
52
52
+
}
53
53
+
}
54
54
+
55
55
+
fetchCategories();
56
56
+
}, []);
57
57
+
58
58
+
// Fetch resources or submissions based on active tab
59
59
+
const fetchData = async () => {
60
60
+
setIsLoading(true);
61
61
+
setError(null);
62
62
+
63
63
+
try {
64
64
+
if (activeTab === 'resources') {
65
65
+
const { data, error } = await supabase
66
66
+
.from('resources')
67
67
+
.select(`
68
68
+
*,
69
69
+
category:categories(id, name, emoji)
70
70
+
`)
71
71
+
.order(sortField, { ascending: sortDirection === 'asc' });
72
72
+
73
73
+
if (error) throw error;
74
74
+
setResources(data || []);
75
75
+
} else if (activeTab === 'submissions') {
76
76
+
const { data, error } = await supabase
77
77
+
.from('resource_submissions')
78
78
+
.select(`
79
79
+
*,
80
80
+
category:categories(id, name, emoji)
81
81
+
`)
82
82
+
.eq('status', 'pending')
83
83
+
.order('created_at', { ascending: false });
84
84
+
85
85
+
if (error) throw error;
86
86
+
setSubmissions(data || []);
87
87
+
}
88
88
+
} catch (error) {
89
89
+
console.error(`Error fetching ${activeTab}:`, error);
90
90
+
setError(`Failed to load ${activeTab}. Please try again.`);
91
91
+
} finally {
92
92
+
setIsLoading(false);
93
93
+
}
94
94
+
};
95
95
+
96
96
+
// Handle approving a submission
97
97
+
const handleApproveSubmission = async (submission) => {
98
98
+
try {
99
99
+
// First, insert the submission as a new resource
100
100
+
const { error: insertError } = await supabase
101
101
+
.from('resources')
102
102
+
.insert([{
103
103
+
name: submission.name,
104
104
+
url: submission.url,
105
105
+
description: submission.description,
106
106
+
domain: submission.domain,
107
107
+
category_id: submission.category_id,
108
108
+
subcategory: submission.subcategory,
109
109
+
quality: 3, // Default quality
110
110
+
featured: false,
111
111
+
is_new: true,
112
112
+
created_at: new Date().toISOString()
113
113
+
}]);
114
114
+
115
115
+
if (insertError) throw insertError;
116
116
+
117
117
+
// Then update the submission status to approved
118
118
+
const { error: updateError } = await supabase
119
119
+
.from('resource_submissions')
120
120
+
.update({ status: 'approved' })
121
121
+
.eq('id', submission.id);
122
122
+
123
123
+
if (updateError) throw updateError;
124
124
+
125
125
+
// Refresh submissions list
126
126
+
fetchData();
127
127
+
128
128
+
} catch (error) {
129
129
+
console.error('Error approving submission:', error);
130
130
+
setError('Failed to approve submission. Please try again.');
131
131
+
}
132
132
+
};
133
133
+
134
134
+
// Handle rejecting a submission
135
135
+
const handleRejectSubmission = async (submissionId) => {
136
136
+
try {
137
137
+
const { error } = await supabase
138
138
+
.from('resource_submissions')
139
139
+
.update({ status: 'rejected' })
140
140
+
.eq('id', submissionId);
141
141
+
142
142
+
if (error) throw error;
143
143
+
144
144
+
// Refresh submissions list
145
145
+
fetchData();
146
146
+
147
147
+
} catch (error) {
148
148
+
console.error('Error rejecting submission:', error);
149
149
+
setError('Failed to reject submission. Please try again.');
150
150
+
}
151
151
+
};
152
152
+
153
153
+
// Handle editing a resource
154
154
+
const handleEditResource = (resource) => {
155
155
+
setEditingResource(resource);
156
156
+
setFormData({
157
157
+
name: resource.name,
158
158
+
url: resource.url,
159
159
+
description: resource.description,
160
160
+
domain: resource.domain,
161
161
+
category_id: resource.category_id,
162
162
+
subcategory: resource.subcategory || '',
163
163
+
quality: resource.quality,
164
164
+
featured: resource.featured
165
165
+
});
166
166
+
};
167
167
+
168
168
+
// Handle saving edited resource
169
169
+
const handleSaveResource = async (e) => {
170
170
+
e.preventDefault();
171
171
+
172
172
+
try {
173
173
+
if (editingResource.id) {
174
174
+
// Update existing resource
175
175
+
const { error } = await supabase
176
176
+
.from('resources')
177
177
+
.update({
178
178
+
name: formData.name,
179
179
+
url: formData.url,
180
180
+
description: formData.description,
181
181
+
domain: formData.domain,
182
182
+
category_id: formData.category_id,
183
183
+
subcategory: formData.subcategory || null,
184
184
+
quality: formData.quality,
185
185
+
featured: formData.featured,
186
186
+
updated_at: new Date().toISOString()
187
187
+
})
188
188
+
.eq('id', editingResource.id);
189
189
+
190
190
+
if (error) throw error;
191
191
+
} else {
192
192
+
// Create new resource
193
193
+
const { error } = await supabase
194
194
+
.from('resources')
195
195
+
.insert([{
196
196
+
name: formData.name,
197
197
+
url: formData.url,
198
198
+
description: formData.description,
199
199
+
domain: formData.domain,
200
200
+
category_id: formData.category_id,
201
201
+
subcategory: formData.subcategory || null,
202
202
+
quality: formData.quality,
203
203
+
featured: formData.featured,
204
204
+
is_new: true,
205
205
+
created_at: new Date().toISOString()
206
206
+
}]);
207
207
+
208
208
+
if (error) throw error;
209
209
+
}
210
210
+
211
211
+
// Reset form and refresh data
212
212
+
setEditingResource(null);
213
213
+
fetchData();
214
214
+
215
215
+
} catch (error) {
216
216
+
console.error('Error saving resource:', error);
217
217
+
setError('Failed to save resource. Please try again.');
218
218
+
}
219
219
+
};
220
220
+
221
221
+
// Handle deleting a resource
222
222
+
const handleDeleteResource = async (resourceId) => {
223
223
+
if (!window.confirm('Are you sure you want to delete this resource?')) {
224
224
+
return;
225
225
+
}
226
226
+
227
227
+
try {
228
228
+
const { error } = await supabase
229
229
+
.from('resources')
230
230
+
.delete()
231
231
+
.eq('id', resourceId);
232
232
+
233
233
+
if (error) throw error;
234
234
+
235
235
+
// Refresh resources list
236
236
+
fetchData();
237
237
+
238
238
+
} catch (error) {
239
239
+
console.error('Error deleting resource:', error);
240
240
+
setError('Failed to delete resource. Please try again.');
241
241
+
}
242
242
+
};
243
243
+
244
244
+
// Handle form input changes
245
245
+
const handleInputChange = (e) => {
246
246
+
const { name, value, type, checked } = e.target;
247
247
+
setFormData(prev => ({
248
248
+
...prev,
249
249
+
[name]: type === 'checkbox' ? checked : value
250
250
+
}));
251
251
+
};
252
252
+
253
253
+
// Auto-extract domain from URL
254
254
+
useEffect(() => {
255
255
+
if (formData.url) {
256
256
+
try {
257
257
+
const url = new URL(formData.url);
258
258
+
setFormData(prev => ({ ...prev, domain: url.hostname }));
259
259
+
} catch (error) {
260
260
+
// Not a valid URL yet, ignore
261
261
+
}
262
262
+
}
263
263
+
}, [formData.url]);
264
264
+
265
265
+
// Filter resources based on search term and category
266
266
+
const filteredResources = resources.filter(resource => {
267
267
+
const matchesSearch = searchTerm === '' ||
268
268
+
resource.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
269
269
+
resource.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
270
270
+
resource.domain.toLowerCase().includes(searchTerm.toLowerCase());
271
271
+
272
272
+
const matchesCategory = categoryFilter === '' ||
273
273
+
resource.category_id === categoryFilter;
274
274
+
275
275
+
return matchesSearch && matchesCategory;
276
276
+
});
277
277
+
278
278
+
// Handle sort change
279
279
+
const handleSortChange = (field) => {
280
280
+
if (sortField === field) {
281
281
+
// Toggle direction if clicking the same field
282
282
+
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
283
283
+
} else {
284
284
+
// Default to descending for new field
285
285
+
setSortField(field);
286
286
+
setSortDirection('desc');
287
287
+
}
288
288
+
};
289
289
+
290
290
+
// Render quality stars
291
291
+
const renderQualityStars = (quality) => {
292
292
+
const stars = [];
293
293
+
for (let i = 1; i <= 5; i++) {
294
294
+
stars.push(
295
295
+
<span
296
296
+
key={i}
297
297
+
className={`quality-star ${i <= quality ? 'filled' : 'empty'}`}
298
298
+
>
299
299
+
★
300
300
+
</span>
301
301
+
);
302
302
+
}
303
303
+
return stars;
304
304
+
};
305
305
+
306
306
+
return (
307
307
+
<div className="admin-resources-manager">
308
308
+
<h1>Resources Manager</h1>
309
309
+
310
310
+
{error && <div className="admin-error-alert">{error}</div>}
311
311
+
312
312
+
<div className="admin-tabs">
313
313
+
<button
314
314
+
className={activeTab === 'resources' ? 'active' : ''}
315
315
+
onClick={() => setActiveTab('resources')}
316
316
+
>
317
317
+
Resources
318
318
+
</button>
319
319
+
<button
320
320
+
className={activeTab === 'submissions' ? 'active' : ''}
321
321
+
onClick={() => setActiveTab('submissions')}
322
322
+
>
323
323
+
Submissions {submissions.length > 0 && <span className="badge">{submissions.length}</span>}
324
324
+
</button>
325
325
+
</div>
326
326
+
327
327
+
{activeTab === 'resources' && (
328
328
+
<div className="resources-management">
329
329
+
<div className="admin-toolbar">
330
330
+
<div className="admin-search">
331
331
+
<input
332
332
+
type="text"
333
333
+
placeholder="Search resources..."
334
334
+
value={searchTerm}
335
335
+
onChange={(e) => setSearchTerm(e.target.value)}
336
336
+
/>
337
337
+
</div>
338
338
+
339
339
+
<div className="admin-filter">
340
340
+
<select
341
341
+
value={categoryFilter}
342
342
+
onChange={(e) => setCategoryFilter(e.target.value)}
343
343
+
>
344
344
+
<option value="">All Categories</option>
345
345
+
{categories.map(category => (
346
346
+
<option key={category.id} value={category.id}>
347
347
+
{category.emoji} {category.name}
348
348
+
</option>
349
349
+
))}
350
350
+
</select>
351
351
+
</div>
352
352
+
353
353
+
<button
354
354
+
className="admin-add-button"
355
355
+
onClick={() => {
356
356
+
setEditingResource({});
357
357
+
setFormData({
358
358
+
name: '',
359
359
+
url: '',
360
360
+
description: '',
361
361
+
domain: '',
362
362
+
category_id: '',
363
363
+
subcategory: '',
364
364
+
quality: 3,
365
365
+
featured: false
366
366
+
});
367
367
+
}}
368
368
+
>
369
369
+
Add New Resource
370
370
+
</button>
371
371
+
</div>
372
372
+
373
373
+
{isLoading ? (
374
374
+
<div className="admin-loading">Loading resources...</div>
375
375
+
) : (
376
376
+
<>
377
377
+
<table className="admin-table">
378
378
+
<thead>
379
379
+
<tr>
380
380
+
<th onClick={() => handleSortChange('name')} className="sortable">
381
381
+
Name {sortField === 'name' && (sortDirection === 'asc' ? '↑' : '↓')}
382
382
+
</th>
383
383
+
<th>Domain</th>
384
384
+
<th onClick={() => handleSortChange('category_id')} className="sortable">
385
385
+
Category {sortField === 'category_id' && (sortDirection === 'asc' ? '↑' : '↓')}
386
386
+
</th>
387
387
+
<th onClick={() => handleSortChange('quality')} className="sortable">
388
388
+
Quality {sortField === 'quality' && (sortDirection === 'asc' ? '↑' : '↓')}
389
389
+
</th>
390
390
+
<th onClick={() => handleSortChange('created_at')} className="sortable">
391
391
+
Added {sortField === 'created_at' && (sortDirection === 'asc' ? '↑' : '↓')}
392
392
+
</th>
393
393
+
<th>Featured</th>
394
394
+
<th>Actions</th>
395
395
+
</tr>
396
396
+
</thead>
397
397
+
<tbody>
398
398
+
{filteredResources.map(resource => (
399
399
+
<tr key={resource.id} className={resource.is_new ? 'new-resource' : ''}>
400
400
+
<td>{resource.name}</td>
401
401
+
<td>
402
402
+
<a href={resource.url} target="_blank" rel="noopener noreferrer">
403
403
+
{resource.domain}
404
404
+
</a>
405
405
+
</td>
406
406
+
<td>
407
407
+
{resource.category?.emoji} {resource.category?.name}
408
408
+
{resource.subcategory && <span className="subcategory"> / {resource.subcategory}</span>}
409
409
+
</td>
410
410
+
<td>{renderQualityStars(resource.quality)}</td>
411
411
+
<td>{new Date(resource.created_at).toLocaleDateString()}</td>
412
412
+
<td>{resource.featured ? '✅' : '❌'}</td>
413
413
+
<td className="action-buttons">
414
414
+
<button className="edit-button" onClick={() => handleEditResource(resource)}>Edit</button>
415
415
+
<button
416
416
+
className="delete-button"
417
417
+
onClick={() => handleDeleteResource(resource.id)}
418
418
+
>
419
419
+
Delete
420
420
+
</button>
421
421
+
</td>
422
422
+
</tr>
423
423
+
))}
424
424
+
</tbody>
425
425
+
</table>
426
426
+
427
427
+
{filteredResources.length === 0 && (
428
428
+
<div className="admin-no-results">No resources found matching your criteria.</div>
429
429
+
)}
430
430
+
</>
431
431
+
)}
432
432
+
</div>
433
433
+
)}
434
434
+
435
435
+
{activeTab === 'submissions' && (
436
436
+
<div className="submissions-management">
437
437
+
<h2>Pending Submissions</h2>
438
438
+
439
439
+
{isLoading ? (
440
440
+
<div className="admin-loading">Loading submissions...</div>
441
441
+
) : (
442
442
+
<>
443
443
+
{submissions.length === 0 ? (
444
444
+
<div className="admin-no-results">No pending submissions.</div>
445
445
+
) : (
446
446
+
<table className="admin-table">
447
447
+
<thead>
448
448
+
<tr>
449
449
+
<th>Name</th>
450
450
+
<th>URL</th>
451
451
+
<th>Category</th>
452
452
+
<th>Submitted By</th>
453
453
+
<th>Date</th>
454
454
+
<th>Actions</th>
455
455
+
</tr>
456
456
+
</thead>
457
457
+
<tbody>
458
458
+
{submissions.map(submission => (
459
459
+
<tr key={submission.id}>
460
460
+
<td>{submission.name}</td>
461
461
+
<td>
462
462
+
<a href={submission.url} target="_blank" rel="noopener noreferrer">
463
463
+
{submission.domain}
464
464
+
</a>
465
465
+
</td>
466
466
+
<td>
467
467
+
{submission.category?.emoji} {submission.category?.name}
468
468
+
{submission.subcategory && <span className="subcategory"> / {submission.subcategory}</span>}
469
469
+
</td>
470
470
+
<td>
471
471
+
{submission.submitter_handle || submission.submitter_email || 'Anonymous'}
472
472
+
</td>
473
473
+
<td>{new Date(submission.created_at).toLocaleDateString()}</td>
474
474
+
<td className="action-buttons">
475
475
+
<button
476
476
+
className="approve-button"
477
477
+
onClick={() => handleApproveSubmission(submission)}
478
478
+
>
479
479
+
Approve
480
480
+
</button>
481
481
+
<button
482
482
+
className="reject-button"
483
483
+
onClick={() => handleRejectSubmission(submission.id)}
484
484
+
>
485
485
+
Reject
486
486
+
</button>
487
487
+
<button
488
488
+
className="edit-approve-button"
489
489
+
onClick={() => {
490
490
+
setEditingResource({
491
491
+
...submission,
492
492
+
isSubmission: true
493
493
+
});
494
494
+
setFormData({
495
495
+
name: submission.name,
496
496
+
url: submission.url,
497
497
+
description: submission.description,
498
498
+
domain: submission.domain,
499
499
+
category_id: submission.category_id,
500
500
+
subcategory: submission.subcategory || '',
501
501
+
quality: 3,
502
502
+
featured: false
503
503
+
});
504
504
+
}}
505
505
+
>
506
506
+
Edit & Approve
507
507
+
</button>
508
508
+
</td>
509
509
+
</tr>
510
510
+
))}
511
511
+
</tbody>
512
512
+
</table>
513
513
+
)}
514
514
+
</>
515
515
+
)}
516
516
+
</div>
517
517
+
)}
518
518
+
519
519
+
{/* Edit/Create Resource Modal */}
520
520
+
{editingResource && (
521
521
+
<div className="admin-modal">
522
522
+
<div className="admin-modal-content">
523
523
+
<h2>{editingResource.id ? 'Edit Resource' : 'Add New Resource'}</h2>
524
524
+
525
525
+
<form onSubmit={handleSaveResource}>
526
526
+
<div className="form-group">
527
527
+
<label htmlFor="name">Resource Name*</label>
528
528
+
<input
529
529
+
type="text"
530
530
+
id="name"
531
531
+
name="name"
532
532
+
value={formData.name}
533
533
+
onChange={handleInputChange}
534
534
+
required
535
535
+
/>
536
536
+
</div>
537
537
+
538
538
+
<div className="form-group">
539
539
+
<label htmlFor="url">URL*</label>
540
540
+
<input
541
541
+
type="url"
542
542
+
id="url"
543
543
+
name="url"
544
544
+
value={formData.url}
545
545
+
onChange={handleInputChange}
546
546
+
required
547
547
+
/>
548
548
+
</div>
549
549
+
550
550
+
<div className="form-group">
551
551
+
<label htmlFor="description">Description*</label>
552
552
+
<textarea
553
553
+
id="description"
554
554
+
name="description"
555
555
+
value={formData.description}
556
556
+
onChange={handleInputChange}
557
557
+
required
558
558
+
rows="3"
559
559
+
/>
560
560
+
</div>
561
561
+
562
562
+
<div className="form-group">
563
563
+
<label htmlFor="domain">Domain</label>
564
564
+
<input
565
565
+
type="text"
566
566
+
id="domain"
567
567
+
name="domain"
568
568
+
value={formData.domain}
569
569
+
onChange={handleInputChange}
570
570
+
disabled
571
571
+
/>
572
572
+
<small>This field is auto-filled from the URL.</small>
573
573
+
</div>
574
574
+
575
575
+
<div className="form-row">
576
576
+
<div className="form-group">
577
577
+
<label htmlFor="category_id">Category*</label>
578
578
+
<select
579
579
+
id="category_id"
580
580
+
name="category_id"
581
581
+
value={formData.category_id}
582
582
+
onChange={handleInputChange}
583
583
+
required
584
584
+
>
585
585
+
<option value="">Select a category</option>
586
586
+
{categories.map(category => (
587
587
+
<option key={category.id} value={category.id}>
588
588
+
{category.emoji} {category.name}
589
589
+
</option>
590
590
+
))}
591
591
+
</select>
592
592
+
</div>
593
593
+
594
594
+
<div className="form-group">
595
595
+
<label htmlFor="subcategory">Subcategory</label>
596
596
+
<input
597
597
+
type="text"
598
598
+
id="subcategory"
599
599
+
name="subcategory"
600
600
+
value={formData.subcategory}
601
601
+
onChange={handleInputChange}
602
602
+
placeholder="e.g., Feed Tools"
603
603
+
/>
604
604
+
</div>
605
605
+
</div>
606
606
+
607
607
+
<div className="form-row">
608
608
+
<div className="form-group">
609
609
+
<label htmlFor="quality">Quality Rating*</label>
610
610
+
<select
611
611
+
id="quality"
612
612
+
name="quality"
613
613
+
value={formData.quality}
614
614
+
onChange={handleInputChange}
615
615
+
required
616
616
+
>
617
617
+
<option value="5">5 - Excellent</option>
618
618
+
<option value="4">4 - Very Good</option>
619
619
+
<option value="3">3 - Good</option>
620
620
+
<option value="2">2 - Fair</option>
621
621
+
<option value="1">1 - Poor</option>
622
622
+
</select>
623
623
+
</div>
624
624
+
625
625
+
<div className="form-group checkbox-group">
626
626
+
<label>
627
627
+
<input
628
628
+
type="checkbox"
629
629
+
name="featured"
630
630
+
checked={formData.featured}
631
631
+
onChange={handleInputChange}
632
632
+
/>
633
633
+
Featured Resource
634
634
+
</label>
635
635
+
</div>
636
636
+
</div>
637
637
+
638
638
+
{editingResource.isSubmission && (
639
639
+
<div className="form-group">
640
640
+
<p className="submission-info">
641
641
+
<strong>Submitted by:</strong> {editingResource.submitter_handle || editingResource.submitter_email || 'Anonymous'}<br />
642
642
+
<strong>Submitted on:</strong> {new Date(editingResource.created_at).toLocaleString()}
643
643
+
</p>
644
644
+
</div>
645
645
+
)}
646
646
+
647
647
+
<div className="form-buttons">
648
648
+
<button type="submit" className="save-button">
649
649
+
{editingResource.isSubmission ? 'Approve with Changes' : 'Save Resource'}
650
650
+
</button>
651
651
+
<button
652
652
+
type="button"
653
653
+
className="cancel-button"
654
654
+
onClick={() => setEditingResource(null)}
655
655
+
>
656
656
+
Cancel
657
657
+
</button>
658
658
+
</div>
659
659
+
</form>
660
660
+
</div>
661
661
+
</div>
662
662
+
)}
663
663
+
</div>
664
664
+
);
665
665
+
};
666
666
+
667
667
+
export default ResourcesManager;
+151
src/components/Resources/ResourceAdmin.js
Reviewed
···
1
1
+
import React, { useState, useEffect } from 'react';
2
2
+
import {
3
3
+
getResources,
4
4
+
getPendingSubmissions,
5
5
+
approveSubmission
6
6
+
} from '../services/supabaseClient';
7
7
+
8
8
+
const ResourceAdmin = () => {
9
9
+
const [resources, setResources] = useState([]);
10
10
+
const [submissions, setSubmissions] = useState([]);
11
11
+
const [activeTab, setActiveTab] = useState('resources');
12
12
+
const [isLoading, setIsLoading] = useState(true);
13
13
+
14
14
+
useEffect(() => {
15
15
+
fetchData();
16
16
+
}, [activeTab]);
17
17
+
18
18
+
async function fetchData() {
19
19
+
setIsLoading(true);
20
20
+
try {
21
21
+
if (activeTab === 'resources') {
22
22
+
const data = await getResources();
23
23
+
setResources(data);
24
24
+
} else if (activeTab === 'submissions') {
25
25
+
const data = await getPendingSubmissions();
26
26
+
setSubmissions(data);
27
27
+
}
28
28
+
} catch (error) {
29
29
+
console.error('Error fetching data:', error);
30
30
+
} finally {
31
31
+
setIsLoading(false);
32
32
+
}
33
33
+
}
34
34
+
35
35
+
const handleApproveSubmission = async (id) => {
36
36
+
try {
37
37
+
await approveSubmission(id);
38
38
+
// Refresh the submissions list
39
39
+
const data = await getPendingSubmissions();
40
40
+
setSubmissions(data);
41
41
+
} catch (error) {
42
42
+
console.error('Error approving submission:', error);
43
43
+
}
44
44
+
};
45
45
+
46
46
+
// More admin functions here...
47
47
+
48
48
+
return (
49
49
+
<div className="admin-dashboard">
50
50
+
<h1>Resource Admin</h1>
51
51
+
52
52
+
<div className="admin-tabs">
53
53
+
<button
54
54
+
className={activeTab === 'resources' ? 'active' : ''}
55
55
+
onClick={() => setActiveTab('resources')}
56
56
+
>
57
57
+
Manage Resources
58
58
+
</button>
59
59
+
<button
60
60
+
className={activeTab === 'submissions' ? 'active' : ''}
61
61
+
onClick={() => setActiveTab('submissions')}
62
62
+
>
63
63
+
Review Submissions
64
64
+
</button>
65
65
+
</div>
66
66
+
67
67
+
{isLoading ? (
68
68
+
<p>Loading...</p>
69
69
+
) : (
70
70
+
<div className="admin-content">
71
71
+
{activeTab === 'resources' && (
72
72
+
<div className="resources-management">
73
73
+
<h2>Resources ({resources.length})</h2>
74
74
+
{/* Resource management table */}
75
75
+
<table className="admin-table">
76
76
+
<thead>
77
77
+
<tr>
78
78
+
<th>Name</th>
79
79
+
<th>Category</th>
80
80
+
<th>Quality</th>
81
81
+
<th>Featured</th>
82
82
+
<th>Actions</th>
83
83
+
</tr>
84
84
+
</thead>
85
85
+
<tbody>
86
86
+
{resources.map(resource => (
87
87
+
<tr key={resource.id}>
88
88
+
<td>{resource.name}</td>
89
89
+
<td>{resource.category.name}</td>
90
90
+
<td>{resource.quality}</td>
91
91
+
<td>{resource.featured ? 'Yes' : 'No'}</td>
92
92
+
<td>
93
93
+
<button>Edit</button>
94
94
+
<button>Delete</button>
95
95
+
</td>
96
96
+
</tr>
97
97
+
))}
98
98
+
</tbody>
99
99
+
</table>
100
100
+
</div>
101
101
+
)}
102
102
+
103
103
+
{activeTab === 'submissions' && (
104
104
+
<div className="submissions-review">
105
105
+
<h2>Pending Submissions ({submissions.length})</h2>
106
106
+
{/* Submission review table */}
107
107
+
{submissions.length === 0 ? (
108
108
+
<p>No pending submissions.</p>
109
109
+
) : (
110
110
+
<table className="admin-table">
111
111
+
<thead>
112
112
+
<tr>
113
113
+
<th>Name</th>
114
114
+
<th>URL</th>
115
115
+
<th>Category</th>
116
116
+
<th>Submitted</th>
117
117
+
<th>Actions</th>
118
118
+
</tr>
119
119
+
</thead>
120
120
+
<tbody>
121
121
+
{submissions.map(submission => (
122
122
+
<tr key={submission.id}>
123
123
+
<td>{submission.name}</td>
124
124
+
<td>
125
125
+
<a href={submission.url} target="_blank" rel="noopener noreferrer">
126
126
+
{submission.domain}
127
127
+
</a>
128
128
+
</td>
129
129
+
<td>{submission.category.name}</td>
130
130
+
<td>{new Date(submission.created_at).toLocaleDateString()}</td>
131
131
+
<td>
132
132
+
<button onClick={() => handleApproveSubmission(submission.id)}>
133
133
+
Approve
134
134
+
</button>
135
135
+
<button>Reject</button>
136
136
+
<button>View Details</button>
137
137
+
</td>
138
138
+
</tr>
139
139
+
))}
140
140
+
</tbody>
141
141
+
</table>
142
142
+
)}
143
143
+
</div>
144
144
+
)}
145
145
+
</div>
146
146
+
)}
147
147
+
</div>
148
148
+
);
149
149
+
};
150
150
+
151
151
+
export default ResourceAdmin;
+191
src/components/Resources/ResourceSubmission.js
Reviewed
···
1
1
+
import React, { useState, useEffect } from 'react';
2
2
+
import { submitResource, getCategories } from '../services/supabaseClient';
3
3
+
4
4
+
const ResourceSubmission = () => {
5
5
+
const [categories, setCategories] = useState([]);
6
6
+
const [subcategories, setSubcategories] = useState([]);
7
7
+
const [selectedCategory, setSelectedCategory] = useState('');
8
8
+
const [formData, setFormData] = useState({
9
9
+
name: '',
10
10
+
url: '',
11
11
+
description: '',
12
12
+
domain: '',
13
13
+
category_id: '',
14
14
+
subcategory_id: '',
15
15
+
submitter_email: '',
16
16
+
submitter_handle: ''
17
17
+
});
18
18
+
const [isSubmitting, setIsSubmitting] = useState(false);
19
19
+
const [submitSuccess, setSubmitSuccess] = useState(false);
20
20
+
const [error, setError] = useState(null);
21
21
+
22
22
+
useEffect(() => {
23
23
+
// Fetch categories
24
24
+
async function fetchCategories() {
25
25
+
try {
26
26
+
const data = await getCategories();
27
27
+
setCategories(data);
28
28
+
} catch (error) {
29
29
+
console.error('Error fetching categories:', error);
30
30
+
}
31
31
+
}
32
32
+
33
33
+
fetchCategories();
34
34
+
}, []);
35
35
+
36
36
+
// Auto-extract domain from URL
37
37
+
useEffect(() => {
38
38
+
if (formData.url) {
39
39
+
try {
40
40
+
const url = new URL(formData.url);
41
41
+
setFormData(prev => ({ ...prev, domain: url.hostname }));
42
42
+
} catch (error) {
43
43
+
// Not a valid URL yet, ignore
44
44
+
}
45
45
+
}
46
46
+
}, [formData.url]);
47
47
+
48
48
+
const handleInputChange = (e) => {
49
49
+
const { name, value } = e.target;
50
50
+
setFormData(prev => ({ ...prev, [name]: value }));
51
51
+
52
52
+
// When category changes, fetch relevant subcategories
53
53
+
if (name === 'category_id') {
54
54
+
setSelectedCategory(value);
55
55
+
// You would add API call to get subcategories here
56
56
+
}
57
57
+
};
58
58
+
59
59
+
const handleSubmit = async (e) => {
60
60
+
e.preventDefault();
61
61
+
setIsSubmitting(true);
62
62
+
setError(null);
63
63
+
64
64
+
try {
65
65
+
await submitResource(formData);
66
66
+
setSubmitSuccess(true);
67
67
+
// Reset form
68
68
+
setFormData({
69
69
+
name: '',
70
70
+
url: '',
71
71
+
description: '',
72
72
+
domain: '',
73
73
+
category_id: '',
74
74
+
subcategory_id: '',
75
75
+
submitter_email: '',
76
76
+
submitter_handle: ''
77
77
+
});
78
78
+
} catch (error) {
79
79
+
setError('There was an error submitting your resource. Please try again.');
80
80
+
console.error('Submission error:', error);
81
81
+
} finally {
82
82
+
setIsSubmitting(false);
83
83
+
}
84
84
+
};
85
85
+
86
86
+
return (
87
87
+
<div className="submission-form-container">
88
88
+
<h2>Submit a Resource</h2>
89
89
+
<p>Know a great tool for Bluesky? Submit it here for consideration.</p>
90
90
+
91
91
+
{submitSuccess ? (
92
92
+
<div className="success-message">
93
93
+
<h3>Thank you for your submission!</h3>
94
94
+
<p>Your resource has been submitted for review. We'll consider adding it to our directory.</p>
95
95
+
<button onClick={() => setSubmitSuccess(false)}>Submit Another Resource</button>
96
96
+
</div>
97
97
+
) : (
98
98
+
<form onSubmit={handleSubmit}>
99
99
+
{error && <div className="error-message">{error}</div>}
100
100
+
101
101
+
<div className="form-group">
102
102
+
<label htmlFor="name">Resource Name*</label>
103
103
+
<input
104
104
+
type="text"
105
105
+
id="name"
106
106
+
name="name"
107
107
+
value={formData.name}
108
108
+
onChange={handleInputChange}
109
109
+
required
110
110
+
/>
111
111
+
</div>
112
112
+
113
113
+
<div className="form-group">
114
114
+
<label htmlFor="url">URL*</label>
115
115
+
<input
116
116
+
type="url"
117
117
+
id="url"
118
118
+
name="url"
119
119
+
value={formData.url}
120
120
+
onChange={handleInputChange}
121
121
+
required
122
122
+
/>
123
123
+
</div>
124
124
+
125
125
+
<div className="form-group">
126
126
+
<label htmlFor="description">Description*</label>
127
127
+
<textarea
128
128
+
id="description"
129
129
+
name="description"
130
130
+
value={formData.description}
131
131
+
onChange={handleInputChange}
132
132
+
required
133
133
+
/>
134
134
+
</div>
135
135
+
136
136
+
<div className="form-group">
137
137
+
<label htmlFor="category_id">Category*</label>
138
138
+
<select
139
139
+
id="category_id"
140
140
+
name="category_id"
141
141
+
value={formData.category_id}
142
142
+
onChange={handleInputChange}
143
143
+
required
144
144
+
>
145
145
+
<option value="">Select a category</option>
146
146
+
{categories.map(category => (
147
147
+
<option key={category.id} value={category.id}>
148
148
+
{category.emoji} {category.name}
149
149
+
</option>
150
150
+
))}
151
151
+
</select>
152
152
+
</div>
153
153
+
154
154
+
{/* Add subcategory dropdown */}
155
155
+
156
156
+
<div className="form-group">
157
157
+
<label htmlFor="submitter_email">Your Email (optional)</label>
158
158
+
<input
159
159
+
type="email"
160
160
+
id="submitter_email"
161
161
+
name="submitter_email"
162
162
+
value={formData.submitter_email}
163
163
+
onChange={handleInputChange}
164
164
+
/>
165
165
+
</div>
166
166
+
167
167
+
<div className="form-group">
168
168
+
<label htmlFor="submitter_handle">Your Bluesky Handle (optional)</label>
169
169
+
<input
170
170
+
type="text"
171
171
+
id="submitter_handle"
172
172
+
name="submitter_handle"
173
173
+
value={formData.submitter_handle}
174
174
+
onChange={handleInputChange}
175
175
+
/>
176
176
+
</div>
177
177
+
178
178
+
<button
179
179
+
type="submit"
180
180
+
className="submit-button"
181
181
+
disabled={isSubmitting}
182
182
+
>
183
183
+
{isSubmitting ? 'Submitting...' : 'Submit Resource'}
184
184
+
</button>
185
185
+
</form>
186
186
+
)}
187
187
+
</div>
188
188
+
);
189
189
+
};
190
190
+
191
191
+
export default ResourceSubmission;
+104
src/components/Resources/Resources.css
Reviewed
···
303
303
opacity: 0.8;
304
304
}
305
305
306
306
+
/* Add these styles to your existing Resources.css file */
307
307
+
308
308
+
/* New badge styling */
309
309
+
.new-badge {
310
310
+
background-color: #007aff;
311
311
+
color: white;
312
312
+
padding: 2px 8px;
313
313
+
border-radius: 12px;
314
314
+
font-size: 0.7rem;
315
315
+
font-weight: bold;
316
316
+
margin-left: 8px;
317
317
+
display: inline-block;
318
318
+
vertical-align: middle;
319
319
+
animation: pulse 2s infinite;
320
320
+
}
321
321
+
322
322
+
@keyframes pulse {
323
323
+
0% {
324
324
+
transform: scale(1);
325
325
+
}
326
326
+
50% {
327
327
+
transform: scale(1.05);
328
328
+
}
329
329
+
100% {
330
330
+
transform: scale(1);
331
331
+
}
332
332
+
}
333
333
+
334
334
+
/* Resource header to display name and new badge on same line */
335
335
+
.resource-header {
336
336
+
display: flex;
337
337
+
align-items: center;
338
338
+
margin-bottom: 8px;
339
339
+
}
340
340
+
341
341
+
/* New filter toggle styling */
342
342
+
.new-filter {
343
343
+
margin-left: 15px;
344
344
+
display: flex;
345
345
+
align-items: center;
346
346
+
}
347
347
+
348
348
+
.toggle-label {
349
349
+
display: flex;
350
350
+
align-items: center;
351
351
+
cursor: pointer;
352
352
+
}
353
353
+
354
354
+
.toggle-label input[type="checkbox"] {
355
355
+
margin-right: 8px;
356
356
+
appearance: none;
357
357
+
position: relative;
358
358
+
width: 40px;
359
359
+
height: 20px;
360
360
+
background-color: #ddd;
361
361
+
border-radius: 20px;
362
362
+
transition: background-color 0.3s;
363
363
+
cursor: pointer;
364
364
+
}
365
365
+
366
366
+
.toggle-label input[type="checkbox"]:checked {
367
367
+
background-color: #007aff;
368
368
+
}
369
369
+
370
370
+
.toggle-label input[type="checkbox"]::before {
371
371
+
content: '';
372
372
+
position: absolute;
373
373
+
width: 16px;
374
374
+
height: 16px;
375
375
+
border-radius: 50%;
376
376
+
top: 2px;
377
377
+
left: 2px;
378
378
+
background-color: white;
379
379
+
transition: transform 0.3s;
380
380
+
}
381
381
+
382
382
+
.toggle-label input[type="checkbox"]:checked::before {
383
383
+
transform: translateX(20px);
384
384
+
}
385
385
+
386
386
+
.toggle-text {
387
387
+
font-size: 0.9rem;
388
388
+
font-weight: 500;
389
389
+
}
390
390
+
391
391
+
/* Make filters more responsive on mobile */
392
392
+
@media (max-width: 768px) {
393
393
+
.filter-options .filter-dropdowns {
394
394
+
flex-direction: column;
395
395
+
align-items: flex-start;
396
396
+
gap: 10px;
397
397
+
}
398
398
+
399
399
+
.category-filter-dropdown,
400
400
+
.quality-filter,
401
401
+
.new-filter {
402
402
+
width: 100%;
403
403
+
}
404
404
+
405
405
+
.filter-select {
406
406
+
width: 100%;
407
407
+
}
408
408
+
}
409
409
+
306
410
@keyframes spin {
307
411
0% { transform: rotate(0deg); }
308
412
100% { transform: rotate(360deg); }
+138
-1611
src/components/Resources/Resources.js
Reviewed
···
1
1
// src/components/Resources/Resources.jsx
2
2
import React, { useState, useEffect, useMemo } from 'react';
3
3
import './Resources.css';
4
4
-
import { Link } from 'react-router-dom';
5
4
import ResourceLoader from './ResourceLoader';
5
5
+
import { supabase } from '../../lib/supabaseClient';
6
6
7
7
const Resources = () => {
8
8
// State management
9
9
+
const [resources, setResources] = useState([]);
9
10
const [activeCategory, setActiveCategory] = useState('All');
10
11
const [searchQuery, setSearchQuery] = useState('');
11
12
const [qualityFilter, setQualityFilter] = useState('All');
13
13
+
const [showNewOnly, setShowNewOnly] = useState(false);
12
14
const [isLoading, setIsLoading] = useState(true);
13
15
14
16
// Category emojis mapping
···
27
29
'Misc': '🔮'
28
30
};
29
31
30
30
-
// Resources data structure with expanded items from the second file
31
31
-
const resourcesData = [
32
32
-
// Analytics & Metrics - Personal Stats
33
33
-
{
34
34
-
name: "Alt Text Rating Tool",
35
35
-
url: "https://dame.is/ratingalttext",
36
36
-
category: "Analytics",
37
37
-
subcategory: "Personal Stats",
38
38
-
description: "Check how consistently you use alt text",
39
39
-
domain: "dame.is",
40
40
-
quality: 5,
41
41
-
featured: true
42
42
-
},
43
43
-
{
44
44
-
name: "Skeet Reviewer",
45
45
-
url: "https://reviewer.skeet.tools",
46
46
-
category: "Analytics",
47
47
-
subcategory: "Personal Stats",
48
48
-
description: "Use the KonMari method to sort through your old posts",
49
49
-
domain: "skeet.tools",
50
50
-
quality: 5,
51
51
-
featured: true
52
52
-
},
53
53
-
{
54
54
-
name: "Venn Diagram",
55
55
-
url: "https://venn.aviva.gay/dame.bsky.social",
56
56
-
category: "Analytics",
57
57
-
subcategory: "Personal Stats",
58
58
-
description: "Visualize your social graph",
59
59
-
domain: "aviva.gay",
60
60
-
quality: 4,
61
61
-
featured: false
62
62
-
},
63
63
-
{
64
64
-
name: "SkyZoo",
65
65
-
url: "https://skyzoo.blue/",
66
66
-
category: "Analytics",
67
67
-
subcategory: "Personal Stats",
68
68
-
description: "Profile metrics and fun stats",
69
69
-
domain: "skyzoo.blue",
70
70
-
quality: 4,
71
71
-
featured: false
72
72
-
},
73
73
-
{
74
74
-
name: "SkyKit",
75
75
-
url: "http://skykit.blue",
76
76
-
category: "Analytics",
77
77
-
subcategory: "Personal Stats",
78
78
-
description: "Bluesky analytics",
79
79
-
domain: "skykit.blue",
80
80
-
quality: 4,
81
81
-
featured: true
82
82
-
},
83
83
-
{
84
84
-
name: "Skeetstats",
85
85
-
url: "https://bsky.app/profile/skeetstats.xyz",
86
86
-
category: "Analytics",
87
87
-
subcategory: "Personal Stats",
88
88
-
description: "Track your Bluesky stats",
89
89
-
domain: "skeetstats.xyz",
90
90
-
quality: 3,
91
91
-
featured: false
92
92
-
},
93
93
-
{
94
94
-
name: "Skircle",
95
95
-
url: "http://skircle.me",
96
96
-
category: "Analytics",
97
97
-
subcategory: "Personal Stats",
98
98
-
description: "Interaction circles visualization",
99
99
-
domain: "skircle.me",
100
100
-
quality: 3,
101
101
-
featured: false
102
102
-
},
103
103
-
{
104
104
-
name: "Bluesky Counter",
105
105
-
url: "https://blueskycounter.com/",
106
106
-
category: "Analytics",
107
107
-
subcategory: "Personal Stats",
108
108
-
description: "Count various metrics for your profile",
109
109
-
domain: "blueskycounter.com",
110
110
-
quality: 3,
111
111
-
featured: false
112
112
-
},
113
113
-
{
114
114
-
name: "ClearSky",
115
115
-
url: "http://clearsky.app",
116
116
-
category: "Analytics",
117
117
-
subcategory: "Personal Stats",
118
118
-
description: "Transparent block and list analytics",
119
119
-
domain: "clearsky.app",
120
120
-
quality: 4,
121
121
-
featured: true
122
122
-
},
123
123
-
{
124
124
-
name: "Blueview",
125
125
-
url: "https://blueview.app/login",
126
126
-
category: "Analytics",
127
127
-
subcategory: "Personal Stats",
128
128
-
description: "Insights and analytics for your profile",
129
129
-
domain: "blueview.app",
130
130
-
quality: 4,
131
131
-
featured: true
132
132
-
},
133
133
-
{
134
134
-
name: "Bskypt",
135
135
-
url: "https://bskypt.vercel.app",
136
136
-
category: "Analytics",
137
137
-
subcategory: "Personal Stats",
138
138
-
description: "Receipt-like profile stats",
139
139
-
domain: "vercel.app",
140
140
-
quality: 3,
141
141
-
featured: false
142
142
-
},
143
143
-
{
144
144
-
name: "Posts Heatmap Generator",
145
145
-
url: "https://bluesky-heatmap.fly.dev",
146
146
-
category: "Analytics",
147
147
-
subcategory: "Personal Stats",
148
148
-
description: "Create a heatmap of your posting activity",
149
149
-
domain: "fly.dev",
150
150
-
quality: 3,
151
151
-
featured: false
152
152
-
},
153
153
-
{
154
154
-
name: "Dopplersky",
155
155
-
url: "https://dopplersky.com",
156
156
-
category: "Analytics",
157
157
-
subcategory: "Personal Stats",
158
158
-
description: "Find your Twitter-to-Bluesky doppelgangers",
159
159
-
domain: "dopplersky.com",
160
160
-
quality: 3,
161
161
-
featured: false
162
162
-
},
163
163
-
{
164
164
-
name: "Skystats",
165
165
-
url: "https://skystats.mariozechner.at/",
166
166
-
category: "Analytics",
167
167
-
subcategory: "Personal Stats",
168
168
-
description: "Comprehensive profile statistics",
169
169
-
domain: "mariozechner.at",
170
170
-
quality: 3,
171
171
-
featured: false
172
172
-
},
173
173
-
{
174
174
-
name: "Best Time to Post",
175
175
-
url: "https://bluesky.notemation.com/best-time-to-post",
176
176
-
category: "Analytics",
177
177
-
subcategory: "Personal Stats",
178
178
-
description: "See your social graph's active times",
179
179
-
domain: "notemation.com",
180
180
-
quality: 3,
181
181
-
featured: false
182
182
-
},
183
183
-
184
184
-
// Analytics & Metrics - Platform Stats
185
185
-
{
186
186
-
name: "Bcounter",
187
187
-
url: "http://bcounter.nat.vg",
188
188
-
category: "Analytics",
189
189
-
subcategory: "Platform Stats",
190
190
-
description: "Realtime user growth dashboard",
191
191
-
domain: "nat.vg",
192
192
-
quality: 4,
193
193
-
featured: false
194
194
-
},
195
195
-
{
196
196
-
name: "Emojistats",
197
197
-
url: "https://emojistats.bsky.sh",
198
198
-
category: "Analytics",
199
199
-
subcategory: "Platform Stats",
200
200
-
description: "Real-time emoji usage data",
201
201
-
domain: "bsky.sh",
202
202
-
quality: 3,
203
203
-
featured: false
204
204
-
},
205
205
-
{
206
206
-
name: "Bluesky Post Count and Author Stats",
207
207
-
url: "https://bsky.jazco.dev/stats",
208
208
-
category: "Analytics",
209
209
-
subcategory: "Platform Stats",
210
210
-
description: "Platform-wide statistics",
211
211
-
domain: "jazco.dev",
212
212
-
quality: 3,
213
213
-
featured: false
214
214
-
},
215
215
-
{
216
216
-
name: "Bluesky Population Size Guide",
217
217
-
url: "https://observablehq.com/d/58c2cd234ca376b8",
218
218
-
category: "Analytics",
219
219
-
subcategory: "Platform Stats",
220
220
-
description: "Visual guide to Bluesky user population",
221
221
-
domain: "observablehq.com",
222
222
-
quality: 3,
223
223
-
featured: false
224
224
-
},
225
225
-
{
226
226
-
name: "Top 500 Users List",
227
227
-
url: "https://vqv.app/index.html",
228
228
-
category: "Analytics",
229
229
-
subcategory: "Platform Stats",
230
230
-
description: "List of top Bluesky users by followers",
231
231
-
domain: "vqv.app",
232
232
-
quality: 3,
233
233
-
featured: false
234
234
-
},
235
235
-
{
236
236
-
name: "Handles Directory",
237
237
-
url: "https://blue.mackuba.eu/directory/",
238
238
-
category: "Analytics",
239
239
-
subcategory: "Platform Stats",
240
240
-
description: "Browse Bluesky handles by domain",
241
241
-
domain: "mackuba.eu",
242
242
-
quality: 3,
243
243
-
featured: false
244
244
-
},
245
245
-
{
246
246
-
name: "BlueTube",
247
247
-
url: "https://bluetube.fyi/",
248
248
-
category: "Analytics",
249
249
-
subcategory: "Platform Stats",
250
250
-
description: "Hottest YouTube links on Bluesky",
251
251
-
domain: "bluetube.fyi",
252
252
-
quality: 3,
253
253
-
featured: false
254
254
-
},
255
255
-
{
256
256
-
name: "BSkyCharts",
257
257
-
url: "https://bskycharts.edavis.dev/bluesky-day.html",
258
258
-
category: "Analytics",
259
259
-
subcategory: "Platform Stats",
260
260
-
description: "Charts and statistics for Bluesky",
261
261
-
domain: "edavis.dev",
262
262
-
quality: 3,
263
263
-
featured: false
264
264
-
},
265
265
-
266
266
-
// Services & AppViews
267
267
-
{
268
268
-
name: "Mutesky",
269
269
-
url: "https://mutesky.app/",
270
270
-
category: "Services",
271
271
-
subcategory: "AppViews",
272
272
-
description: "Manage your muted words in bulk",
273
273
-
domain: "mutesky.app",
274
274
-
quality: 4,
275
275
-
featured: false
276
276
-
},
277
277
-
{
278
278
-
name: "Frontpage",
279
279
-
url: "https://frontpage.fyi",
280
280
-
category: "Services",
281
281
-
subcategory: "AppViews",
282
282
-
description: "Decentralized link aggregator",
283
283
-
domain: "frontpage.fyi",
284
284
-
quality: 5,
285
285
-
featured: true
286
286
-
},
287
287
-
{
288
288
-
name: "WhiteWind",
289
289
-
url: "https://whtwnd.com/about",
290
290
-
category: "Services",
291
291
-
subcategory: "AppViews",
292
292
-
description: "Markdown blogging service",
293
293
-
domain: "whtwnd.com",
294
294
-
quality: 4,
295
295
-
featured: false
296
296
-
},
297
297
-
{
298
298
-
name: "Skylights",
299
299
-
url: "https://skylights.my/profile/watwa.re",
300
300
-
category: "Services",
301
301
-
subcategory: "AppViews",
302
302
-
description: "Track and review favorite media",
303
303
-
domain: "skylights.my",
304
304
-
quality: 4,
305
305
-
featured: false
306
306
-
},
307
307
-
{
308
308
-
name: "BookHive",
309
309
-
url: "https://bookhive.buzz/",
310
310
-
category: "Services",
311
311
-
subcategory: "AppViews",
312
312
-
description: "Goodreads on AT Proto",
313
313
-
domain: "bookhive.buzz",
314
314
-
quality: 4,
315
315
-
featured: false
316
316
-
},
317
317
-
{
318
318
-
name: "Linkat",
319
319
-
url: "https://linkat.blue",
320
320
-
category: "Services",
321
321
-
subcategory: "AppViews",
322
322
-
description: "Link in bio for Bluesky",
323
323
-
domain: "linkat.blue",
324
324
-
quality: 4,
325
325
-
featured: false
326
326
-
},
327
327
-
{
328
328
-
name: "psky.social",
329
329
-
url: "https://psky.social",
330
330
-
category: "Services",
331
331
-
subcategory: "AppViews",
332
332
-
description: "Chatroom for Bluesky users",
333
333
-
domain: "psky.social",
334
334
-
quality: 3,
335
335
-
featured: false
336
336
-
},
337
337
-
{
338
338
-
name: "atproto.camp",
339
339
-
url: "https://atproto.camp",
340
340
-
category: "Services",
341
341
-
subcategory: "AppViews",
342
342
-
description: "Earn badges for protocol activity",
343
343
-
domain: "atproto.camp",
344
344
-
quality: 3,
345
345
-
featured: false
346
346
-
},
347
347
-
{
348
348
-
name: "pinksea.art",
349
349
-
url: "https://pinksea.art/",
350
350
-
category: "Services",
351
351
-
subcategory: "AppViews",
352
352
-
description: "Oekaki (doodle) on the ATprotocol",
353
353
-
domain: "pinksea.art",
354
354
-
quality: 4,
355
355
-
featured: true
356
356
-
},
357
357
-
{
358
358
-
name: "poll.blue",
359
359
-
url: "https://poll.blue/post",
360
360
-
category: "Services",
361
361
-
subcategory: "AppViews",
362
362
-
description: "Polls for Bluesky",
363
363
-
domain: "poll.blue",
364
364
-
quality: 4,
365
365
-
featured: false
366
366
-
},
367
367
-
{
368
368
-
name: "Blue Bots, Done Quick",
369
369
-
url: "http://bluebotsdonequick.com",
370
370
-
category: "Services",
371
371
-
subcategory: "AppViews",
372
372
-
description: "Create bots for Bluesky easily",
373
373
-
domain: "bluebotsdonequick.com",
374
374
-
quality: 3,
375
375
-
featured: false
376
376
-
},
377
377
-
{
378
378
-
name: "teal.fm",
379
379
-
url: "https://teal.fm",
380
380
-
category: "Services",
381
381
-
subcategory: "AppViews",
382
382
-
description: "Music tracking and discovery",
383
383
-
domain: "teal.fm",
384
384
-
quality: 4,
385
385
-
featured: false
386
386
-
},
387
387
-
{
388
388
-
name: "Hugfairy",
389
389
-
url: "https://bsky.app/profile/hugfairy.bsky.social",
390
390
-
category: "Services",
391
391
-
subcategory: "AppViews",
392
392
-
description: "Send a hug to someone on Bluesky",
393
393
-
domain: "bsky.app",
394
394
-
quality: 3,
395
395
-
featured: false
396
396
-
},
397
397
-
{
398
398
-
name: "BlueNotify",
399
399
-
url: "https://apps.apple.com/us/app/bluenotify/id6738239349",
400
400
-
category: "Services",
401
401
-
subcategory: "AppViews",
402
402
-
description: "Post notifications for Bluesky",
403
403
-
domain: "apple.com",
404
404
-
quality: 4,
405
405
-
featured: false
406
406
-
},
407
407
-
{
408
408
-
name: "ATFile",
409
409
-
url: "https://github.com/ziodotsh/atfile",
410
410
-
category: "Services",
411
411
-
subcategory: "AppViews",
412
412
-
description: "Share files on a PDS",
413
413
-
domain: "github.com",
414
414
-
quality: 3,
415
415
-
featured: false
416
416
-
},
417
417
-
{
418
418
-
name: "Bluecast",
419
419
-
url: "https://www.bluecast.app",
420
420
-
category: "Services",
421
421
-
subcategory: "AppViews",
422
422
-
description: "Real-time audio streaming service",
423
423
-
domain: "bluecast.app",
424
424
-
quality: 4,
425
425
-
featured: false
426
426
-
},
427
427
-
{
428
428
-
name: "Blue Place",
429
429
-
url: "https://place.blue",
430
430
-
category: "Services",
431
431
-
subcategory: "AppViews",
432
432
-
description: "r/place, but for Bluesky",
433
433
-
domain: "place.blue",
434
434
-
quality: 3,
435
435
-
featured: false
436
436
-
},
437
437
-
{
438
438
-
name: "pastesphere",
439
439
-
url: "https://pastesphere.link/",
440
440
-
category: "Services",
441
441
-
subcategory: "AppViews",
442
442
-
description: "Paste-bin on the AT Protocol",
443
443
-
domain: "pastesphere.link",
444
444
-
quality: 3,
445
445
-
featured: false
446
446
-
},
447
447
-
{
448
448
-
name: "Recipe Exchange",
449
449
-
url: "https://recipe.exchange/",
450
450
-
category: "Services",
451
451
-
subcategory: "AppViews",
452
452
-
description: "Share and discover recipes",
453
453
-
domain: "recipe.exchange",
454
454
-
quality: 4,
455
455
-
featured: false
456
456
-
},
457
457
-
{
458
458
-
name: "skywatched",
459
459
-
url: "https://skywatched.app/",
460
460
-
category: "Services",
461
461
-
subcategory: "AppViews",
462
462
-
description: "Review and track movies",
463
463
-
domain: "skywatched.app",
464
464
-
quality: 4,
465
465
-
featured: false
466
466
-
},
467
467
-
{
468
468
-
name: "Ruthub",
469
469
-
url: "https://ruthub.com",
470
470
-
category: "Services",
471
471
-
subcategory: "AppViews",
472
472
-
description: "Kanban on AT Proto",
473
473
-
domain: "ruthub.com",
474
474
-
quality: 3,
475
475
-
featured: false
476
476
-
},
477
477
-
{
478
478
-
name: "dazzle.fm",
479
479
-
url: "https://dazzle.fm/trends",
480
480
-
category: "Services",
481
481
-
subcategory: "AppViews",
482
482
-
description: "What's happening on Bluesky",
483
483
-
domain: "dazzle.fm",
484
484
-
quality: 3,
485
485
-
featured: false
486
486
-
},
487
487
-
488
488
-
// Data Management
489
489
-
{
490
490
-
name: "Bulk Thread Gating",
491
491
-
url: "https://boat.kelinci.net/bsky-threadgate-applicator",
492
492
-
category: "Data",
493
493
-
subcategory: "Management",
494
494
-
description: "Bulk retroactive thread gating",
495
495
-
domain: "kelinci.net",
496
496
-
quality: 3,
497
497
-
featured: false
498
498
-
},
499
499
-
{
500
500
-
name: "SkySweeper",
501
501
-
url: "https://skysweeper.p8.lu",
502
502
-
category: "Data",
503
503
-
subcategory: "Management",
504
504
-
description: "Auto-delete old skeets",
505
505
-
domain: "p8.lu",
506
506
-
quality: 4,
507
507
-
featured: false
508
508
-
},
509
509
-
{
510
510
-
name: "Skeetgen",
511
511
-
url: "https://mary-ext.github.io/skeetgen/",
512
512
-
category: "Data",
513
513
-
subcategory: "Management",
514
514
-
description: "Generate an easily viewable archive of your posts",
515
515
-
domain: "github.io",
516
516
-
quality: 4,
517
517
-
featured: false
518
518
-
},
519
519
-
{
520
520
-
name: "Profile Cleaner",
521
521
-
url: "https://bsky.jazco.dev/cleanup",
522
522
-
category: "Data",
523
523
-
subcategory: "Management",
524
524
-
description: "Clean up your Bluesky profile",
525
525
-
domain: "jazco.dev",
526
526
-
quality: 3,
527
527
-
featured: false
528
528
-
},
529
529
-
{
530
530
-
name: "Backup Tool",
531
531
-
url: "https://observablehq.com/@aendra/bluesky-backup-tool",
532
532
-
category: "Data",
533
533
-
subcategory: "Management",
534
534
-
description: "Back up your Bluesky data",
535
535
-
domain: "observablehq.com",
536
536
-
quality: 3,
537
537
-
featured: false
538
538
-
},
539
539
-
{
540
540
-
name: "Tweet Deleter",
541
541
-
url: "http://tweetdeleter.com",
542
542
-
category: "Data",
543
543
-
subcategory: "Management",
544
544
-
description: "Delete your old tweets",
545
545
-
domain: "tweetdeleter.com",
546
546
-
quality: 3,
547
547
-
featured: false
548
548
-
},
549
549
-
{
550
550
-
name: "redact.dev",
551
551
-
url: "http://redact.dev",
552
552
-
category: "Data",
553
553
-
subcategory: "Management",
554
554
-
description: "Delete posts from various platforms",
555
555
-
domain: "redact.dev",
556
556
-
quality: 3,
557
557
-
featured: false
558
558
-
},
559
559
-
{
560
560
-
name: "Blockparty",
561
561
-
url: "http://blockpartyapp.com",
562
562
-
category: "Data",
563
563
-
subcategory: "Management",
564
564
-
description: "Manage blocks across platforms",
565
565
-
domain: "blockpartyapp.com",
566
566
-
quality: 3,
567
567
-
featured: false
568
568
-
},
569
569
-
{
570
570
-
name: "Porto",
571
571
-
url: "https://chromewebstore.google.com/detail/porto-import-your-tweets/ckilhjdflnaakopknngigiggfpnjaaop",
572
572
-
category: "Data",
573
573
-
subcategory: "Management",
574
574
-
description: "Import your tweets to Bluesky",
575
575
-
domain: "google.com",
576
576
-
quality: 4,
577
577
-
featured: false
578
578
-
},
579
579
-
{
580
580
-
name: "BlueArk",
581
581
-
url: "https://blueark.app",
582
582
-
category: "Data",
583
583
-
subcategory: "Management",
584
584
-
description: "Move your tweets to Bluesky",
585
585
-
domain: "blueark.app",
586
586
-
quality: 4,
587
587
-
featured: false
588
588
-
},
589
589
-
590
590
-
// Network Management
591
591
-
{
592
592
-
name: "Network Analyzer",
593
593
-
url: "http://bsky-follow-finder.theo.io",
594
594
-
category: "Network",
595
595
-
subcategory: "Management",
596
596
-
description: "Find and analyze your network connections",
597
597
-
domain: "theo.io",
598
598
-
quality: 4,
599
599
-
featured: true
600
600
-
},
601
601
-
{
602
602
-
name: "Gentle Unfollow",
603
603
-
url: "https://bsky.cam.fyi/unfollow",
604
604
-
category: "Network",
605
605
-
subcategory: "Management",
606
606
-
description: "Track and manage who you're following",
607
607
-
domain: "cam.fyi",
608
608
-
quality: 4,
609
609
-
featured: true
610
610
-
},
611
611
-
{
612
612
-
name: "Sky Follower Bridge",
613
613
-
url: "https://chromewebstore.google.com/detail/sky-follower-bridge/behhbpbpmailcnfbjagknjngnfdojpko/",
614
614
-
category: "Network",
615
615
-
subcategory: "Management",
616
616
-
description: "Find your Twitter follows",
617
617
-
domain: "google.com",
618
618
-
quality: 4,
619
619
-
featured: false
620
620
-
},
621
621
-
{
622
622
-
name: "StarterPacks.net",
623
623
-
url: "https://www.starterpacks.net",
624
624
-
category: "Network",
625
625
-
subcategory: "Management",
626
626
-
description: "Explore starter packs",
627
627
-
domain: "starterpacks.net",
628
628
-
quality: 4,
629
629
-
featured: false
630
630
-
},
631
631
-
{
632
632
-
name: "Follower Explorer",
633
633
-
url: "https://bluesky-followers.advaith.io",
634
634
-
category: "Network",
635
635
-
subcategory: "Management",
636
636
-
description: "Explore your followers",
637
637
-
domain: "advaith.io",
638
638
-
quality: 3,
639
639
-
featured: false
640
640
-
},
641
641
-
{
642
642
-
name: "cleanfollow",
643
643
-
url: "https://cleanfollow-bsky.pages.dev",
644
644
-
category: "Network",
645
645
-
subcategory: "Management",
646
646
-
description: "Select inactive or blocked accounts to unfollow",
647
647
-
domain: "pages.dev",
648
648
-
quality: 3,
649
649
-
featured: false
650
650
-
},
651
651
-
{
652
652
-
name: "Bluesky Follower Info",
653
653
-
url: "https://chromewebstore.google.com/detail/bluesky-follower-info/fokpfcfpgdlmnbjajbdeofkemfblbnbh",
654
654
-
category: "Network",
655
655
-
subcategory: "Management",
656
656
-
description: "Chrome extension for follower info",
657
657
-
domain: "google.com",
658
658
-
quality: 3,
659
659
-
featured: false
660
660
-
},
661
661
-
{
662
662
-
name: "unfollow.blue",
663
663
-
url: "https://unfollow.blue/",
664
664
-
category: "Network",
665
665
-
subcategory: "Management",
666
666
-
description: "Track unfollows and follows",
667
667
-
domain: "unfollow.blue",
668
668
-
quality: 3,
669
669
-
featured: false
670
670
-
},
671
671
-
{
672
672
-
name: "Blockenheimer",
673
673
-
url: "https://blockenheimer.click/",
674
674
-
category: "Network",
675
675
-
subcategory: "Management",
676
676
-
description: "Block large amounts of accounts",
677
677
-
domain: "blockenheimer.click",
678
678
-
quality: 3,
679
679
-
featured: false
680
680
-
},
681
681
-
{
682
682
-
name: "Convert Starter Pack to List",
683
683
-
url: "https://nws-bot.us/bskyStarterPack.php",
684
684
-
category: "Network",
685
685
-
subcategory: "Management",
686
686
-
description: "Convert starter packs to lists",
687
687
-
domain: "nws-bot.us",
688
688
-
quality: 3,
689
689
-
featured: false
690
690
-
},
691
691
-
{
692
692
-
name: "List Copier",
693
693
-
url: "https://bsky.cam.fyi/lists",
694
694
-
category: "Network",
695
695
-
subcategory: "Management",
696
696
-
description: "Copy lists between accounts",
697
697
-
domain: "cam.fyi",
698
698
-
quality: 3,
699
699
-
featured: false
700
700
-
},
701
701
-
{
702
702
-
name: "listfluff",
703
703
-
url: "https://github.com/mollypup/listfluff?tab=readme-ov-file",
704
704
-
category: "Network",
705
705
-
subcategory: "Management",
706
706
-
description: "Add and remove users from Bluesky lists",
707
707
-
domain: "github.com",
708
708
-
quality: 3,
709
709
-
featured: false
710
710
-
},
711
711
-
{
712
712
-
name: "Which Pack",
713
713
-
url: "https://whichpack.com/",
714
714
-
category: "Network",
715
715
-
subcategory: "Management",
716
716
-
description: "See what starterpacks you're in",
717
717
-
domain: "whichpack.com",
718
718
-
quality: 3,
719
719
-
featured: false
720
720
-
},
721
721
-
{
722
722
-
name: "AT Orbital Laser",
723
723
-
url: "https://at-orbital-laser.aesthr.com/",
724
724
-
category: "Network",
725
725
-
subcategory: "Management",
726
726
-
description: "Block a user and their followers",
727
727
-
domain: "aesthr.com",
728
728
-
quality: 3,
729
729
-
featured: false
730
730
-
},
731
731
-
732
732
-
// Alternative Clients
733
733
-
{
734
734
-
name: "deck.blue",
735
735
-
url: "http://deck.blue",
736
736
-
category: "Clients",
737
737
-
subcategory: "Alternative",
738
738
-
description: "TweetDeck for Bluesky",
739
739
-
domain: "deck.blue",
740
740
-
quality: 4,
741
741
-
featured: false
742
742
-
},
743
743
-
{
744
744
-
name: "Graysky",
745
745
-
url: "https://graysky.app",
746
746
-
category: "Clients",
747
747
-
subcategory: "Alternative",
748
748
-
description: "Alternative mobile client",
749
749
-
domain: "graysky.app",
750
750
-
quality: 5,
751
751
-
featured: false
752
752
-
},
753
753
-
{
754
754
-
name: "Skeets App",
755
755
-
url: "https://www.skeetsapp.com",
756
756
-
category: "Clients",
757
757
-
subcategory: "Alternative",
758
758
-
description: "Third-party Bluesky client",
759
759
-
domain: "skeetsapp.com",
760
760
-
quality: 4,
761
761
-
featured: false
762
762
-
},
763
763
-
{
764
764
-
name: "Ouranos",
765
765
-
url: "https://useouranos.app/",
766
766
-
category: "Clients",
767
767
-
subcategory: "Alternative",
768
768
-
description: "Alternative Bluesky client",
769
769
-
domain: "useouranos.app",
770
770
-
quality: 4,
771
771
-
featured: false
772
772
-
},
773
773
-
{
774
774
-
name: "Butterfly",
775
775
-
url: "https://apps.apple.com/us/app/butterfly-for-bluesky/id6738070758",
776
776
-
category: "Clients",
777
777
-
subcategory: "Alternative",
778
778
-
description: "Bluesky for Apple Watch",
779
779
-
domain: "apple.com",
780
780
-
quality: 3,
781
781
-
featured: false
782
782
-
},
783
783
-
{
784
784
-
name: "Ucho-ten",
785
785
-
url: "https://app.ucho-ten.net/",
786
786
-
category: "Clients",
787
787
-
subcategory: "Alternative",
788
788
-
description: "Alternative Bluesky client",
789
789
-
domain: "ucho-ten.net",
790
790
-
quality: 4,
791
791
-
featured: false
792
792
-
},
793
793
-
{
794
794
-
name: "Swablu",
795
795
-
url: "https://swablu.pages.dev/#/login",
796
796
-
category: "Clients",
797
797
-
subcategory: "Alternative",
798
798
-
description: "Web-based Bluesky client",
799
799
-
domain: "pages.dev",
800
800
-
quality: 3,
801
801
-
featured: false
802
802
-
},
803
803
-
{
804
804
-
name: "Bluejeans",
805
805
-
url: "https://bluejeans.app/",
806
806
-
category: "Clients",
807
807
-
subcategory: "Alternative",
808
808
-
description: "Alternative Bluesky client",
809
809
-
domain: "bluejeans.app",
810
810
-
quality: 3,
811
811
-
featured: false
812
812
-
},
813
813
-
814
814
-
// Labelers & Moderation
815
815
-
{
816
816
-
name: "US Politics Labeler",
817
817
-
url: "https://bsky.app/profile/uspol.bluesky.bot",
818
818
-
category: "Moderation",
819
819
-
subcategory: "Labelers",
820
820
-
description: "Labels political content",
821
821
-
domain: "bsky.app",
822
822
-
quality: 4,
823
823
-
featured: true
824
824
-
},
825
825
-
{
826
826
-
name: "Pronouns Labeler",
827
827
-
url: "https://bsky.app/profile/pronouns.adorable.mom",
828
828
-
category: "Moderation",
829
829
-
subcategory: "Labelers",
830
830
-
description: "Adds pronoun information to profiles",
831
831
-
domain: "bsky.app",
832
832
-
quality: 4,
833
833
-
featured: true
834
834
-
},
835
835
-
{
836
836
-
name: "Labeler List",
837
837
-
url: "https://blue.mackuba.eu/labellers/",
838
838
-
category: "Moderation",
839
839
-
subcategory: "Labelers",
840
840
-
description: "Directory of available labelers",
841
841
-
domain: "mackuba.eu",
842
842
-
quality: 4,
843
843
-
featured: false
844
844
-
},
845
845
-
{
846
846
-
name: "Label Scanner",
847
847
-
url: "https://blue.mackuba.eu/scanner/",
848
848
-
category: "Moderation",
849
849
-
subcategory: "Labelers",
850
850
-
description: "See what labels are on your account",
851
851
-
domain: "mackuba.eu",
852
852
-
quality: 4,
853
853
-
featured: false
854
854
-
},
855
855
-
{
856
856
-
name: "Identity Decentralisation",
857
857
-
url: "https://bsky.app/profile/decentralise.goeo.lol",
858
858
-
category: "Moderation",
859
859
-
subcategory: "Labelers",
860
860
-
description: "Labeler for decentralization identification",
861
861
-
domain: "bsky.app",
862
862
-
quality: 3,
863
863
-
featured: false
864
864
-
},
865
865
-
{
866
866
-
name: "Official Moderation",
867
867
-
url: "https://bsky.app/profile/moderation.bsky.app",
868
868
-
category: "Moderation",
869
869
-
subcategory: "Labelers",
870
870
-
description: "Official Bluesky moderation account",
871
871
-
domain: "bsky.app",
872
872
-
quality: 5,
873
873
-
featured: false
874
874
-
},
875
875
-
{
876
876
-
name: "Profile Records",
877
877
-
url: "https://bsky.app/profile/profile-labels.bossett.social",
878
878
-
category: "Moderation",
879
879
-
subcategory: "Labelers",
880
880
-
description: "Profile record labeler",
881
881
-
domain: "bsky.app",
882
882
-
quality: 3,
883
883
-
featured: false
884
884
-
},
885
885
-
{
886
886
-
name: "Skywatch",
887
887
-
url: "https://bsky.app/profile/skywatch.blue",
888
888
-
category: "Moderation",
889
889
-
subcategory: "Labelers",
890
890
-
description: "Multipurpose labeler",
891
891
-
domain: "bsky.app",
892
892
-
quality: 4,
893
893
-
featured: false
894
894
-
},
895
895
-
{
896
896
-
name: "Khronos",
897
897
-
url: "https://bsky.app/profile/khronos.world",
898
898
-
category: "Moderation",
899
899
-
subcategory: "Labelers",
900
900
-
description: "Time zone labels",
901
901
-
domain: "bsky.app",
902
902
-
quality: 3,
903
903
-
featured: false
904
904
-
},
905
905
-
{
906
906
-
name: "Blacksky",
907
907
-
url: "https://bsky.app/profile/blacksky.app",
908
908
-
category: "Moderation",
909
909
-
subcategory: "Labelers",
910
910
-
description: "Content moderation labeler",
911
911
-
domain: "bsky.app",
912
912
-
quality: 3,
913
913
-
featured: false
914
914
-
},
915
915
-
{
916
916
-
name: "US Gov Contributions",
917
917
-
url: "https://bsky.app/profile/us-gov-funding.bsky.social",
918
918
-
category: "Moderation",
919
919
-
subcategory: "Labelers",
920
920
-
description: "Labels users with government funding",
921
921
-
domain: "bsky.app",
922
922
-
quality: 3,
923
923
-
featured: false
924
924
-
},
925
925
-
{
926
926
-
name: "Screenshots",
927
927
-
url: "https://bsky.app/profile/xblock.aendra.dev",
928
928
-
category: "Moderation",
929
929
-
subcategory: "Labelers",
930
930
-
description: "Labels screenshots",
931
931
-
domain: "bsky.app",
932
932
-
quality: 3,
933
933
-
featured: false
934
934
-
},
935
935
-
{
936
936
-
name: "AI Imagery",
937
937
-
url: "https://bsky.app/profile/aimod.social",
938
938
-
category: "Moderation",
939
939
-
subcategory: "Labelers",
940
940
-
description: "Labels AI-generated imagery",
941
941
-
domain: "bsky.app",
942
942
-
quality: 4,
943
943
-
featured: false
944
944
-
},
945
945
-
{
946
946
-
name: "Content Creator Labels",
947
947
-
url: "https://bsky.app/profile/creatorlabeler.bsky.social",
948
948
-
category: "Moderation",
949
949
-
subcategory: "Labelers",
950
950
-
description: "Labels content creators",
951
951
-
domain: "bsky.app",
952
952
-
quality: 3,
953
953
-
featured: false
954
954
-
},
955
955
-
{
956
956
-
name: "Shiny Posts",
957
957
-
url: "https://bsky.app/profile/shinyposts.awoo.blue",
958
958
-
category: "Moderation",
959
959
-
subcategory: "Labelers",
960
960
-
description: "Labels visually distinct posts",
961
961
-
domain: "bsky.app",
962
962
-
quality: 3,
963
963
-
featured: false
964
964
-
},
965
965
-
{
966
966
-
name: "Mushroom Server Labels",
967
967
-
url: "https://bsky.app/profile/mushroom-labeler.bsky.social",
968
968
-
category: "Moderation",
969
969
-
subcategory: "Labelers",
970
970
-
description: "Labels mushroom content",
971
971
-
domain: "bsky.app",
972
972
-
quality: 3,
973
973
-
featured: false
974
974
-
},
975
975
-
{
976
976
-
name: "Nations",
977
977
-
url: "https://bsky.app/profile/kickflip.renahlee.com",
978
978
-
category: "Moderation",
979
979
-
subcategory: "Labelers",
980
980
-
description: "Labels users by country/nationality",
981
981
-
domain: "bsky.app",
982
982
-
quality: 3,
983
983
-
featured: false
984
984
-
},
985
985
-
986
986
-
// Feeds & Discovery
987
987
-
{
988
988
-
name: "Graze",
989
989
-
url: "https://www.graze.social/",
990
990
-
category: "Feeds",
991
991
-
subcategory: "Feed Tools",
992
992
-
description: "No-Code feed creator",
993
993
-
domain: "graze.social",
994
994
-
quality: 5,
995
995
-
featured: true
996
996
-
},
997
997
-
{
998
998
-
name: "goodfeeds.com",
999
999
-
url: "https://goodfeeds.co/all?p=1",
1000
1000
-
category: "Feeds",
1001
1001
-
subcategory: "Feed Tools",
1002
1002
-
description: "Discover feeds",
1003
1003
-
domain: "goodfeeds.co",
1004
1004
-
quality: 4,
1005
1005
-
featured: false
1006
1006
-
},
1007
1007
-
{
1008
1008
-
name: "Bluefeed",
1009
1009
-
url: "https://www.bluefeed.app",
1010
1010
-
category: "Feeds",
1011
1011
-
subcategory: "Feed Tools",
1012
1012
-
description: "Discover feeds",
1013
1013
-
domain: "bluefeed.app",
1014
1014
-
quality: 4,
1015
1015
-
featured: false
1016
1016
-
},
1017
1017
-
{
1018
1018
-
name: "Quiet Posters",
1019
1019
-
url: "https://bsky.app/profile/did:plc:vpkhqolt662uhesyj6nxm7ys/feed/infreq",
1020
1020
-
category: "Feeds",
1021
1021
-
subcategory: "Discovery",
1022
1022
-
description: "Feed of less frequent posters",
1023
1023
-
domain: "bsky.app",
1024
1024
-
quality: 3,
1025
1025
-
featured: false
1026
1026
-
},
1027
1027
-
{
1028
1028
-
name: "Popular with Friends",
1029
1029
-
url: "https://bsky.app/profile/bsky.app/feed/with-friends",
1030
1030
-
category: "Feeds",
1031
1031
-
subcategory: "Discovery",
1032
1032
-
description: "Posts popular with your friends",
1033
1033
-
domain: "bsky.app",
1034
1034
-
quality: 4,
1035
1035
-
featured: false
1036
1036
-
},
1037
1037
-
{
1038
1038
-
name: "Best of Friends",
1039
1039
-
url: "https://bsky.app/profile/bsky.app/feed/best-of-follows",
1040
1040
-
category: "Feeds",
1041
1041
-
subcategory: "Discovery",
1042
1042
-
description: "Best posts from who you follow",
1043
1043
-
domain: "bsky.app",
1044
1044
-
quality: 4,
1045
1045
-
featured: false
1046
1046
-
},
1047
1047
-
{
1048
1048
-
name: "Trending Links",
1049
1049
-
url: "https://bsky.app/profile/why.bsky.team/feed/links",
1050
1050
-
category: "Feeds",
1051
1051
-
subcategory: "Discovery",
1052
1052
-
description: "Popular links being shared",
1053
1053
-
domain: "bsky.app",
1054
1054
-
quality: 4,
1055
1055
-
featured: false
1056
1056
-
},
1057
1057
-
{
1058
1058
-
name: "Only Posts",
1059
1059
-
url: "https://bsky.app/profile/did:plc:tenurhgjptubkk5zf5qhi3og/feed/only-posts",
1060
1060
-
category: "Feeds",
1061
1061
-
subcategory: "Discovery",
1062
1062
-
description: "No reposts or replies",
1063
1063
-
domain: "bsky.app",
1064
1064
-
quality: 3,
1065
1065
-
featured: false
1066
1066
-
},
1067
1067
-
{
1068
1068
-
name: "Mentions Only",
1069
1069
-
url: "https://bsky.app/profile/flicknow.xyz/feed/mentions",
1070
1070
-
category: "Feeds",
1071
1071
-
subcategory: "Discovery",
1072
1072
-
description: "See only mentions",
1073
1073
-
domain: "bsky.app",
1074
1074
-
quality: 3,
1075
1075
-
featured: false
1076
1076
-
},
1077
1077
-
{
1078
1078
-
name: "My Misses",
1079
1079
-
url: "https://bsky.app/profile/goeo.lol/feed/misses",
1080
1080
-
category: "Feeds",
1081
1081
-
subcategory: "Discovery",
1082
1082
-
description: "See your unliked posts",
1083
1083
-
domain: "bsky.app",
1084
1084
-
quality: 3,
1085
1085
-
featured: false
1086
1086
-
},
1087
1087
-
{
1088
1088
-
name: "My Bangers",
1089
1089
-
url: "https://bsky.app/profile/jaz.bsky.social/feed/bangers",
1090
1090
-
category: "Feeds",
1091
1091
-
subcategory: "Discovery",
1092
1092
-
description: "See your most liked posts",
1093
1093
-
domain: "bsky.app",
1094
1094
-
quality: 3,
1095
1095
-
featured: false
1096
1096
-
},
1097
1097
-
1098
1098
-
// Visualizations
1099
1099
-
{
1100
1100
-
name: "Bluesky by the Second",
1101
1101
-
url: "https://sky.flikq.dev",
1102
1102
-
category: "Visualizations",
1103
1103
-
subcategory: "Firehose",
1104
1104
-
description: "Live visualization of the firehose",
1105
1105
-
domain: "flikq.dev",
1106
1106
-
quality: 3,
1107
1107
-
featured: false
1108
1108
-
},
1109
1109
-
{
1110
1110
-
name: "Final Words",
1111
1111
-
url: "https://deletions.bsky.bad-example.com",
1112
1112
-
category: "Visualizations",
1113
1113
-
subcategory: "Firehose",
1114
1114
-
description: "Glimpses of deleted posts",
1115
1115
-
domain: "bad-example.com",
1116
1116
-
quality: 3,
1117
1117
-
featured: true
1118
1118
-
},
1119
1119
-
{
1120
1120
-
name: "Swearsky",
1121
1121
-
url: "http://swearsky.bagpuss.org",
1122
1122
-
category: "Visualizations",
1123
1123
-
subcategory: "Firehose",
1124
1124
-
description: "Visualize swearing on Bluesky",
1125
1125
-
domain: "bagpuss.org",
1126
1126
-
quality: 3,
1127
1127
-
featured: false
1128
1128
-
},
1129
1129
-
{
1130
1130
-
name: "3D Firehose",
1131
1131
-
url: "https://firehose3d.theo.io",
1132
1132
-
category: "Visualizations",
1133
1133
-
subcategory: "Firehose",
1134
1134
-
description: "3D visualization of Bluesky posts",
1135
1135
-
domain: "theo.io",
1136
1136
-
quality: 3,
1137
1137
-
featured: false
1138
1138
-
},
1139
1139
-
{
1140
1140
-
name: "Firesky",
1141
1141
-
url: "https://firesky.tv",
1142
1142
-
category: "Visualizations",
1143
1143
-
subcategory: "Firehose",
1144
1144
-
description: "Visualize the Bluesky firehose",
1145
1145
-
domain: "firesky.tv",
1146
1146
-
quality: 3,
1147
1147
-
featured: false
1148
1148
-
},
1149
1149
-
{
1150
1150
-
name: "ATProto Firehose Event Counter",
1151
1151
-
url: "https://atproto.netlify.app",
1152
1152
-
category: "Visualizations",
1153
1153
-
subcategory: "Firehose",
1154
1154
-
description: "Count events in the firehose",
1155
1155
-
domain: "netlify.app",
1156
1156
-
quality: 3,
1157
1157
-
featured: false
1158
1158
-
},
1159
1159
-
{
1160
1160
-
name: "Matrix Style Visualization",
1161
1161
-
url: "https://simone.computer/bluerain/",
1162
1162
-
category: "Visualizations",
1163
1163
-
subcategory: "Firehose",
1164
1164
-
description: "Matrix-inspired visualization",
1165
1165
-
domain: "simone.computer",
1166
1166
-
quality: 3,
1167
1167
-
featured: false
1168
1168
-
},
1169
1169
-
{
1170
1170
-
name: "Spaceship Firehose Game",
1171
1171
-
url: "https://spaceshipfirehose.vercel.app/",
1172
1172
-
category: "Visualizations",
1173
1173
-
subcategory: "Firehose",
1174
1174
-
description: "Game powered by Bluesky firehose",
1175
1175
-
domain: "vercel.app",
1176
1176
-
quality: 3,
1177
1177
-
featured: false
1178
1178
-
},
1179
1179
-
{
1180
1180
-
name: "Live Word Cloud",
1181
1181
-
url: "https://flo-bit.dev/bluesky-visualizers/wordcloud",
1182
1182
-
category: "Visualizations",
1183
1183
-
subcategory: "Firehose",
1184
1184
-
description: "Real-time word cloud of posts",
1185
1185
-
domain: "flo-bit.dev",
1186
1186
-
quality: 3,
1187
1187
-
featured: false
1188
1188
-
},
1189
1189
-
{
1190
1190
-
name: "Trending Hashtags",
1191
1191
-
url: "https://flo-bit.dev/bluesky-trending/",
1192
1192
-
category: "Visualizations",
1193
1193
-
subcategory: "Firehose",
1194
1194
-
description: "See trending hashtags",
1195
1195
-
domain: "flo-bit.dev",
1196
1196
-
quality: 3,
1197
1197
-
featured: false
1198
1198
-
},
1199
1199
-
{
1200
1200
-
name: "Emotions Analysis",
1201
1201
-
url: "https://flo-bit.dev/bluesky-visualizers/emotions",
1202
1202
-
category: "Visualizations",
1203
1203
-
subcategory: "Firehose",
1204
1204
-
description: "Analyze emotions in posts",
1205
1205
-
domain: "flo-bit.dev",
1206
1206
-
quality: 3,
1207
1207
-
featured: false
1208
1208
-
},
1209
1209
-
{
1210
1210
-
name: "Imagehose",
1211
1211
-
url: "https://imagehose.net/",
1212
1212
-
category: "Visualizations",
1213
1213
-
subcategory: "Firehose",
1214
1214
-
description: "Stream of images from Bluesky",
1215
1215
-
domain: "imagehose.net",
1216
1216
-
quality: 3,
1217
1217
-
featured: false
1218
1218
-
},
1219
1219
-
{
1220
1220
-
name: "Colors of Bluesky",
1221
1221
-
url: "https://www.bewitched.com/demo/rainbowsky/",
1222
1222
-
category: "Visualizations",
1223
1223
-
subcategory: "Firehose",
1224
1224
-
description: "Color visualization of posts",
1225
1225
-
domain: "bewitched.com",
1226
1226
-
quality: 3,
1227
1227
-
featured: false
1228
1228
-
},
1229
1229
-
1230
1230
-
// Developer Tools
1231
1231
-
{
1232
1232
-
name: "pdsls.dev",
1233
1233
-
url: "https://pdsls.dev/",
1234
1234
-
category: "Development",
1235
1235
-
subcategory: "Tools",
1236
1236
-
description: "Browse AtProto repositories",
1237
1237
-
domain: "pdsls.dev",
1238
1238
-
quality: 5,
1239
1239
-
featured: true
1240
1240
-
},
1241
1241
-
{
1242
1242
-
name: "sdk.blue",
1243
1243
-
url: "http://sdk.blue",
1244
1244
-
category: "Development",
1245
1245
-
subcategory: "Tools",
1246
1246
-
description: "Libraries & SDKs for the AT Protocol",
1247
1247
-
domain: "sdk.blue",
1248
1248
-
quality: 4,
1249
1249
-
featured: false
1250
1250
-
},
1251
1251
-
{
1252
1252
-
name: "atp.tools",
1253
1253
-
url: "https://atp.tools/",
1254
1254
-
category: "Development",
1255
1255
-
subcategory: "Tools",
1256
1256
-
description: "Developer tools for AT Protocol",
1257
1257
-
domain: "atp.tools",
1258
1258
-
quality: 4,
1259
1259
-
featured: false
1260
1260
-
},
1261
1261
-
{
1262
1262
-
name: "Resolve a Bluesky Handle",
1263
1263
-
url: "https://internect.info",
1264
1264
-
category: "Development",
1265
1265
-
subcategory: "Tools",
1266
1266
-
description: "Handle resolution tool",
1267
1267
-
domain: "internect.info",
1268
1268
-
quality: 3,
1269
1269
-
featured: false
1270
1270
-
},
1271
1271
-
{
1272
1272
-
name: "Boat",
1273
1273
-
url: "https://boat.kelinci.net/",
1274
1274
-
category: "Development",
1275
1275
-
subcategory: "Tools",
1276
1276
-
description: "Various technical tools",
1277
1277
-
domain: "kelinci.net",
1278
1278
-
quality: 3,
1279
1279
-
featured: false
1280
1280
-
},
1281
1281
-
{
1282
1282
-
name: "blue.badge",
1283
1283
-
url: "https://badge.blue",
1284
1284
-
category: "Development",
1285
1285
-
subcategory: "Tools",
1286
1286
-
description: "Define, issue, and verify badges",
1287
1287
-
domain: "badge.blue",
1288
1288
-
quality: 3,
1289
1289
-
featured: false
1290
1290
-
},
1291
1291
-
{
1292
1292
-
name: "browser.blue",
1293
1293
-
url: "https://browser.blue/types",
1294
1294
-
category: "Development",
1295
1295
-
subcategory: "Tools",
1296
1296
-
description: "Browse AT Protocol types",
1297
1297
-
domain: "browser.blue",
1298
1298
-
quality: 3,
1299
1299
-
featured: false
1300
1300
-
},
1301
1301
-
{
1302
1302
-
name: "SkyTools",
1303
1303
-
url: "https://skytools.anon5r.com/profile",
1304
1304
-
category: "Development",
1305
1305
-
subcategory: "Tools",
1306
1306
-
description: "Various developer tools",
1307
1307
-
domain: "anon5r.com",
1308
1308
-
quality: 3,
1309
1309
-
featured: false
1310
1310
-
},
1311
1311
-
{
1312
1312
-
name: "Lexicon Community",
1313
1313
-
url: "https://github.com/lexicon-community",
1314
1314
-
category: "Development",
1315
1315
-
subcategory: "Tools",
1316
1316
-
description: "Community-made lexicons",
1317
1317
-
domain: "github.com",
1318
1318
-
quality: 3,
1319
1319
-
featured: false
1320
1320
-
},
1321
1321
-
{
1322
1322
-
name: "atproto-did-web",
1323
1323
-
url: "https://atproto-did-web.lukeacl.com/",
1324
1324
-
category: "Development",
1325
1325
-
subcategory: "Tools",
1326
1326
-
description: "DID web tools for AT Protocol",
1327
1327
-
domain: "lukeacl.com",
1328
1328
-
quality: 3,
1329
1329
-
featured: false
1330
1330
-
},
1331
1331
-
{
1332
1332
-
name: "Manual",
1333
1333
-
url: "https://manual.renahlee.com/",
1334
1334
-
category: "Development",
1335
1335
-
subcategory: "Tools",
1336
1336
-
description: "Set a non-default PLC key",
1337
1337
-
domain: "renahlee.com",
1338
1338
-
quality: 3,
1339
1339
-
featured: false
1340
1340
-
},
1341
1341
-
{
1342
1342
-
name: "Skeetbeaver",
1343
1343
-
url: "https://skeetbeaver.pages.dev/",
1344
1344
-
category: "Development",
1345
1345
-
subcategory: "Tools",
1346
1346
-
description: "Assorted tools for retrieving data",
1347
1347
-
domain: "pages.dev",
1348
1348
-
quality: 3,
1349
1349
-
featured: false
1350
1350
-
},
1351
1351
-
{
1352
1352
-
name: "Bsky Debug Page",
1353
1353
-
url: "https://bsky-debug.app/handle",
1354
1354
-
category: "Development",
1355
1355
-
subcategory: "Tools",
1356
1356
-
description: "Debug Bluesky handles and profiles",
1357
1357
-
domain: "bsky-debug.app",
1358
1358
-
quality: 3,
1359
1359
-
featured: false
1360
1360
-
},
1361
1361
-
{
1362
1362
-
name: "Skyware",
1363
1363
-
url: "https://skyware.js.org",
1364
1364
-
category: "Development",
1365
1365
-
subcategory: "Tools",
1366
1366
-
description: "Package collection for developers",
1367
1367
-
domain: "js.org",
1368
1368
-
quality: 3,
1369
1369
-
featured: false
1370
1370
-
},
1371
1371
-
{
1372
1372
-
name: "Atcute",
1373
1373
-
url: "https://github.com/mary-ext/atcute",
1374
1374
-
category: "Development",
1375
1375
-
subcategory: "Tools",
1376
1376
-
description: "Lightweight TypeScript packages for AT Protocol",
1377
1377
-
domain: "github.com",
1378
1378
-
quality: 4,
1379
1379
-
featured: false
1380
1380
-
},
1381
1381
-
{
1382
1382
-
name: "bluesky-embed",
1383
1383
-
url: "https://github.com/mary-ext/bluesky-embed",
1384
1384
-
category: "Development",
1385
1385
-
subcategory: "Tools",
1386
1386
-
description: "Custom element for embedding Bluesky posts",
1387
1387
-
domain: "github.com",
1388
1388
-
quality: 4,
1389
1389
-
featured: false
1390
1390
-
},
1391
1391
-
{
1392
1392
-
name: "Clearsky API",
1393
1393
-
url: "https://github.com/ClearskyApp06/clearskyservices/blob/main/api.md",
1394
1394
-
category: "Development",
1395
1395
-
subcategory: "Tools",
1396
1396
-
description: "API for ClearSky services",
1397
1397
-
domain: "github.com",
1398
1398
-
quality: 3,
1399
1399
-
featured: false
1400
1400
-
},
1401
1401
-
{
1402
1402
-
name: "Hopper",
1403
1403
-
url: "https://hopper.at/",
1404
1404
-
category: "Development",
1405
1405
-
subcategory: "Tools",
1406
1406
-
description: "AT-URI redirection tool",
1407
1407
-
domain: "hopper.at",
1408
1408
-
quality: 3,
1409
1409
-
featured: false
1410
1410
-
},
1411
1411
-
{
1412
1412
-
name: "atproto-scraping",
1413
1413
-
url: "https://github.com/mary-ext/atproto-scraping",
1414
1414
-
category: "Development",
1415
1415
-
subcategory: "Tools",
1416
1416
-
description: "Scraping tools for AT Protocol",
1417
1417
-
domain: "github.com",
1418
1418
-
quality: 3,
1419
1419
-
featured: false
1420
1420
-
},
1421
1421
-
{
1422
1422
-
name: "TID converter",
1423
1423
-
url: "https://mary.my.id/tools/tid-converter",
1424
1424
-
category: "Development",
1425
1425
-
subcategory: "Tools",
1426
1426
-
description: "Convert between TIDs and dates",
1427
1427
-
domain: "mary.my.id",
1428
1428
-
quality: 3,
1429
1429
-
featured: false
1430
1430
-
},
1431
1431
-
{
1432
1432
-
name: "Weather Vane",
1433
1433
-
url: "https://verify.aviary.domains/",
1434
1434
-
category: "Development",
1435
1435
-
subcategory: "Tools",
1436
1436
-
description: "Domain verification tool",
1437
1437
-
domain: "aviary.domains",
1438
1438
-
quality: 3,
1439
1439
-
featured: false
1440
1440
-
},
1441
1441
-
{
1442
1442
-
name: "TID clock",
1443
1443
-
url: "https://retr0.id/stuff/atclock/",
1444
1444
-
category: "Development",
1445
1445
-
subcategory: "Tools",
1446
1446
-
description: "Visual TID clock",
1447
1447
-
domain: "retr0.id",
1448
1448
-
quality: 3,
1449
1449
-
featured: false
1450
1450
-
},
1451
1451
-
{
1452
1452
-
name: "handles.net",
1453
1453
-
url: "https://handles.net/",
1454
1454
-
category: "Development",
1455
1455
-
subcategory: "Tools",
1456
1456
-
description: "Manage Bluesky handles for your community",
1457
1457
-
domain: "handles.net",
1458
1458
-
quality: 4,
1459
1459
-
featured: false
1460
1460
-
},
1461
1461
-
{
1462
1462
-
name: "Lexidex",
1463
1463
-
url: "https://lexidex.bsky.dev/",
1464
1464
-
category: "Development",
1465
1465
-
subcategory: "Tools",
1466
1466
-
description: "Catalog of lexicons",
1467
1467
-
domain: "bsky.dev",
1468
1468
-
quality: 3,
1469
1469
-
featured: false
1470
1470
-
},
1471
1471
-
1472
1472
-
// Guides & Documentation
1473
1473
-
{
1474
1474
-
name: "Verify Your Account",
1475
1475
-
url: "https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial",
1476
1476
-
category: "Guides",
1477
1477
-
subcategory: "Documentation",
1478
1478
-
description: "How to verify your Bluesky account",
1479
1479
-
domain: "bsky.social",
1480
1480
-
quality: 4,
1481
1481
-
featured: false
1482
1482
-
},
1483
1483
-
{
1484
1484
-
name: "Complete Guide to Bluesky",
1485
1485
-
url: "https://mackuba.eu/2024/02/21/bluesky-guide/",
1486
1486
-
category: "Guides",
1487
1487
-
subcategory: "Documentation",
1488
1488
-
description: "Comprehensive Bluesky guide",
1489
1489
-
domain: "mackuba.eu",
1490
1490
-
quality: 5,
1491
1491
-
featured: false
1492
1492
-
},
1493
1493
-
{
1494
1494
-
name: "Advanced Search Guide",
1495
1495
-
url: "https://bsky.social/about/blog/05-31-2024-search",
1496
1496
-
category: "Guides",
1497
1497
-
subcategory: "Documentation",
1498
1498
-
description: "Guide to using advanced search",
1499
1499
-
domain: "bsky.social",
1500
1500
-
quality: 4,
1501
1501
-
featured: false
1502
1502
-
},
1503
1503
-
{
1504
1504
-
name: "Run your own PDS Server",
1505
1505
-
url: "https://www.youtube.com/watch?v=7-VJvf39xVE&t=4s",
1506
1506
-
category: "Guides",
1507
1507
-
subcategory: "Documentation",
1508
1508
-
description: "How to run your own Bluesky PDS Server",
1509
1509
-
domain: "youtube.com",
1510
1510
-
quality: 4,
1511
1511
-
featured: false
1512
1512
-
},
1513
1513
-
1514
1514
-
// Miscellaneous
1515
1515
-
{
1516
1516
-
name: "Thread Composer",
1517
1517
-
url: "https://bluesky-thread-composer.pages.dev",
1518
1518
-
category: "Misc",
1519
1519
-
subcategory: "Tools",
1520
1520
-
description: "Create and organize threads",
1521
1521
-
domain: "pages.dev",
1522
1522
-
quality: 3,
1523
1523
-
featured: false
1524
1524
-
},
1525
1525
-
{
1526
1526
-
name: "Skyview",
1527
1527
-
url: "https://skyview.social",
1528
1528
-
category: "Misc",
1529
1529
-
subcategory: "Tools",
1530
1530
-
description: "Share threads with people without an account",
1531
1531
-
domain: "skyview.social",
1532
1532
-
quality: 4,
1533
1533
-
featured: false
1534
1534
-
},
1535
1535
-
{
1536
1536
-
name: "down.blue",
1537
1537
-
url: "https://down.blue",
1538
1538
-
category: "Misc",
1539
1539
-
subcategory: "Tools",
1540
1540
-
description: "Video downloader",
1541
1541
-
domain: "down.blue",
1542
1542
-
quality: 3,
1543
1543
-
featured: false
1544
1544
-
},
1545
1545
-
{
1546
1546
-
name: "iOS Shortcuts Collection",
1547
1547
-
url: "https://matthewcassinelli.com/shortcuts/folders/bluesky/",
1548
1548
-
category: "Misc",
1549
1549
-
subcategory: "Tools",
1550
1550
-
description: "Useful iOS shortcuts for Bluesky",
1551
1551
-
domain: "matthewcassinelli.com",
1552
1552
-
quality: 3,
1553
1553
-
featured: false
1554
1554
-
},
1555
1555
-
{
1556
1556
-
name: "Bookmarks/Drafts Workaround",
1557
1557
-
url: "https://bsky.app/profile/dame.bsky.social/post/3lb5wrehvdc2g",
1558
1558
-
category: "Misc",
1559
1559
-
subcategory: "Tools",
1560
1560
-
description: "Workaround for saving bookmarks/drafts",
1561
1561
-
domain: "bsky.app",
1562
1562
-
quality: 3,
1563
1563
-
featured: false
1564
1564
-
},
1565
1565
-
{
1566
1566
-
name: "cobalt.tools",
1567
1567
-
url: "https://cobalt.tools",
1568
1568
-
category: "Misc",
1569
1569
-
subcategory: "Tools",
1570
1570
-
description: "Media saver",
1571
1571
-
domain: "cobalt.tools",
1572
1572
-
quality: 3,
1573
1573
-
featured: false
1574
1574
-
},
1575
1575
-
{
1576
1576
-
name: "Social Profile Widget Generator",
1577
1577
-
url: "https://bsky-widget.srbh.dev",
1578
1578
-
category: "Misc",
1579
1579
-
subcategory: "Tools",
1580
1580
-
description: "Generate profile widgets",
1581
1581
-
domain: "srbh.dev",
1582
1582
-
quality: 3,
1583
1583
-
featured: false
1584
1584
-
},
1585
1585
-
{
1586
1586
-
name: "Bluesky Lore",
1587
1587
-
url: "https://bsky.app/profile/jay.bsky.team/post/3lbd2eaura22r",
1588
1588
-
category: "Misc",
1589
1589
-
subcategory: "Tools",
1590
1590
-
description: "Bluesky lore, as told by Jay",
1591
1591
-
domain: "bsky.app",
1592
1592
-
quality: 3,
1593
1593
-
featured: false
1594
1594
-
},
1595
1595
-
{
1596
1596
-
name: "The Fediverse Report",
1597
1597
-
url: "https://fediversereport.com",
1598
1598
-
category: "Misc",
1599
1599
-
subcategory: "Tools",
1600
1600
-
description: "Bluesky and ATmosphere newsletter",
1601
1601
-
domain: "fediversereport.com",
1602
1602
-
quality: 3,
1603
1603
-
featured: false
32
32
+
// Load saved user preferences from localStorage
33
33
+
useEffect(() => {
34
34
+
const savedPreferences = localStorage.getItem('resourcesPreferences');
35
35
+
if (savedPreferences) {
36
36
+
try {
37
37
+
const preferences = JSON.parse(savedPreferences);
38
38
+
setActiveCategory(preferences.activeCategory || 'All');
39
39
+
setQualityFilter(preferences.qualityFilter || 'All');
40
40
+
setShowNewOnly(preferences.showNewOnly || false);
41
41
+
} catch (error) {
42
42
+
console.error('Error loading preferences:', error);
43
43
+
}
44
44
+
}
45
45
+
}, []);
46
46
+
47
47
+
// Save user preferences to localStorage
48
48
+
useEffect(() => {
49
49
+
const preferences = {
50
50
+
activeCategory,
51
51
+
qualityFilter,
52
52
+
showNewOnly
53
53
+
};
54
54
+
localStorage.setItem('resourcesPreferences', JSON.stringify(preferences));
55
55
+
}, [activeCategory, qualityFilter, showNewOnly]);
56
56
+
57
57
+
// Fetch resources from Supabase
58
58
+
useEffect(() => {
59
59
+
async function fetchResources() {
60
60
+
setIsLoading(true);
61
61
+
try {
62
62
+
// Fetch all resources with category and subcategory data
63
63
+
const { data, error } = await supabase
64
64
+
.from('resources')
65
65
+
.select(`
66
66
+
*,
67
67
+
category:categories(id, name, emoji),
68
68
+
subcategory:subcategories(id, name)
69
69
+
`)
70
70
+
.order('position');
71
71
+
72
72
+
if (error) {
73
73
+
throw error;
74
74
+
}
75
75
+
76
76
+
// Transform data to match the expected format
77
77
+
const formattedResources = data.map(resource => ({
78
78
+
...resource,
79
79
+
category: resource.category.name,
80
80
+
subcategory: resource.subcategory ? resource.subcategory.name : null,
81
81
+
emoji: resource.category.emoji,
82
82
+
url: addUTMParameters(resource.url)
83
83
+
}));
84
84
+
85
85
+
setResources(formattedResources);
86
86
+
} catch (error) {
87
87
+
console.error('Error fetching resources:', error);
88
88
+
// In case of error, we could use local data as fallback
89
89
+
// setResources(localResourcesWithUTM);
90
90
+
} finally {
91
91
+
setIsLoading(false);
92
92
+
}
1604
93
}
1605
1605
-
];
1606
94
1607
1607
-
// Add UTM parameters to all URLs
1608
1608
-
const resourcesWithUTM = resourcesData.map(resource => ({
1609
1609
-
...resource,
1610
1610
-
url: `${resource.url}${resource.url.includes('?') ? '&' : '?'}utm_source=cred.blue&utm_medium=resources&utm_campaign=tools_directory`
1611
1611
-
}));
95
95
+
fetchResources();
96
96
+
}, []);
97
97
+
98
98
+
// Check if a resource is new (added in the last 14 days)
99
99
+
const isNewResource = (date) => {
100
100
+
if (!date) return false;
101
101
+
const resourceDate = new Date(date);
102
102
+
const now = new Date();
103
103
+
const daysDiff = Math.floor((now - resourceDate) / (1000 * 60 * 60 * 24));
104
104
+
return daysDiff < 14;
105
105
+
};
106
106
+
107
107
+
// Add UTM parameters to URLs
108
108
+
const addUTMParameters = (url) => {
109
109
+
const separator = url.includes('?') ? '&' : '?';
110
110
+
return `${url}${separator}utm_source=cred.blue&utm_medium=resources&utm_campaign=tools_directory`;
111
111
+
};
1612
112
1613
113
// Function to share the resources page on Bluesky
1614
114
const shareOnBluesky = () => {
···
1620
120
);
1621
121
};
1622
122
1623
1623
-
// Get all categories
1624
1624
-
const categories = ['All', ...new Set(resourcesWithUTM.map(item => item.category))];
123
123
+
// Get all categories from resources
124
124
+
const categories = useMemo(() => {
125
125
+
if (resources.length === 0) return ['All'];
126
126
+
const categoryNames = [...new Set(resources.map(item => item.category))];
127
127
+
return ['All', ...categoryNames];
128
128
+
}, [resources]);
1625
129
1626
130
// Count resources per category
1627
131
const categoryCounts = useMemo(() => {
1628
1628
-
const counts = { 'All': resourcesWithUTM.length };
1629
1629
-
resourcesWithUTM.forEach(resource => {
132
132
+
const counts = { 'All': resources.length };
133
133
+
resources.forEach(resource => {
1630
134
counts[resource.category] = (counts[resource.category] || 0) + 1;
1631
135
});
1632
136
return counts;
1633
1633
-
}, [resourcesWithUTM]);
137
137
+
}, [resources]);
1634
138
1635
1635
-
// Filter resources based on active category, search query, and quality filter
139
139
+
// Filter resources based on active category, search query, quality filter, and new filter
1636
140
const filteredResources = useMemo(() => {
1637
1637
-
return resourcesWithUTM.filter(resource => {
141
141
+
return resources.filter(resource => {
1638
142
// Filter by category
1639
143
const categoryMatch = activeCategory === 'All' || resource.category === activeCategory;
1640
144
···
1651
155
(qualityFilter === 'Medium' && resource.quality === 3) ||
1652
156
(qualityFilter === 'Low' && resource.quality <= 2);
1653
157
1654
1654
-
return categoryMatch && searchMatch && qualityMatch;
158
158
+
// Filter by "new" status if the toggle is active
159
159
+
const newMatch = !showNewOnly || isNewResource(resource.created_at);
160
160
+
161
161
+
return categoryMatch && searchMatch && qualityMatch && newMatch;
1655
162
});
1656
1656
-
}, [resourcesWithUTM, activeCategory, searchQuery, qualityFilter]);
163
163
+
}, [resources, activeCategory, searchQuery, qualityFilter, showNewOnly]);
1657
164
1658
165
// Get featured resources
1659
166
const featuredResources = useMemo(() => {
1660
1660
-
return resourcesWithUTM.filter(resource => resource.featured);
1661
1661
-
}, [resourcesWithUTM]);
167
167
+
return resources.filter(resource => resource.featured);
168
168
+
}, [resources]);
1662
169
1663
170
// Group resources by category when "All" is selected
1664
171
const resourcesByCategory = useMemo(() => {
···
1676
183
1677
184
// Should show featured section only when All category is selected
1678
185
const shouldShowFeatured = activeCategory === 'All';
1679
1679
-
1680
1680
-
// Simulate loading data
1681
1681
-
useEffect(() => {
1682
1682
-
// Simulate API fetch with a timeout
1683
1683
-
const loadTimer = setTimeout(() => {
1684
1684
-
setIsLoading(false);
1685
1685
-
}, 800);
1686
1686
-
1687
1687
-
return () => clearTimeout(loadTimer);
1688
1688
-
}, []);
1689
186
1690
187
return (
1691
188
<>
···
1733
230
1734
231
<div className="filter-options">
1735
232
<div className="filter-dropdowns">
1736
1736
-
{/* Changed category filter to dropdown */}
233
233
+
{/* Category filter dropdown */}
1737
234
<div className="category-filter-dropdown">
1738
235
<select
1739
236
value={activeCategory}
···
1742
239
>
1743
240
{categories.map(category => (
1744
241
<option key={category} value={category}>
1745
1745
-
{categoryEmojis[category]} {category} ({categoryCounts[category]})
242
242
+
{categoryEmojis[category] || '🔹'} {category} ({categoryCounts[category] || 0})
1746
243
</option>
1747
244
))}
1748
245
</select>
1749
246
</div>
1750
247
248
248
+
{/* Quality filter dropdown */}
1751
249
<div className="quality-filter">
1752
250
<select
1753
251
value={qualityFilter}
···
1760
258
<option value="Low">Low Quality</option>
1761
259
</select>
1762
260
</div>
261
261
+
262
262
+
{/* New resources toggle */}
263
263
+
<div className="new-filter">
264
264
+
<label className="toggle-label">
265
265
+
<input
266
266
+
type="checkbox"
267
267
+
checked={showNewOnly}
268
268
+
onChange={() => setShowNewOnly(!showNewOnly)}
269
269
+
/>
270
270
+
<span className="toggle-text">Recently Added Only</span>
271
271
+
</label>
272
272
+
</div>
1763
273
</div>
1764
274
</div>
1765
275
</div>
···
1770
280
<p className="featured-description">Hand-selected tools that we love and use regularly. These are not sponsored or paid placements.</p>
1771
281
<div className="resources-grid">
1772
282
{featuredResources.map((resource, index) => (
1773
1773
-
<ResourceCard key={`featured-${index}`} resource={resource} />
283
283
+
<ResourceCard
284
284
+
key={`featured-${index}`}
285
285
+
resource={resource}
286
286
+
isNew={isNewResource(resource.created_at)}
287
287
+
/>
1774
288
))}
1775
289
</div>
1776
290
</div>
···
1784
298
{Object.keys(resourcesByCategory).map(category => (
1785
299
<div key={category} className="category-section">
1786
300
<h3 className="category-header">
1787
1787
-
{categoryEmojis[category]} {category} ({resourcesByCategory[category].length})
301
301
+
{categoryEmojis[category] || '🔹'} {category} ({resourcesByCategory[category].length})
1788
302
</h3>
1789
303
<div className="resources-grid">
1790
304
{resourcesByCategory[category].map((resource, index) => (
1791
1791
-
<ResourceCard key={`${category}-${index}`} resource={resource} />
305
305
+
<ResourceCard
306
306
+
key={`${category}-${index}`}
307
307
+
resource={resource}
308
308
+
isNew={isNewResource(resource.created_at)}
309
309
+
/>
1792
310
))}
1793
311
</div>
1794
312
</div>
···
1797
315
) : (
1798
316
// When a specific category is selected
1799
317
<div className="all-resources-section">
1800
1800
-
<h2>{categoryEmojis[activeCategory]} {activeCategory} Resources ({filteredResources.length})</h2>
318
318
+
<h2>{categoryEmojis[activeCategory] || '🔹'} {activeCategory} Resources ({filteredResources.length})</h2>
1801
319
{filteredResources.length > 0 ? (
1802
320
<div className="resources-grid">
1803
321
{filteredResources.map((resource, index) => (
1804
1804
-
<ResourceCard key={index} resource={resource} />
322
322
+
<ResourceCard
323
323
+
key={index}
324
324
+
resource={resource}
325
325
+
isNew={isNewResource(resource.created_at)}
326
326
+
/>
1805
327
))}
1806
328
</div>
1807
329
) : (
···
1820
342
};
1821
343
1822
344
// ResourceCard component for displaying individual resources
1823
1823
-
const ResourceCard = ({ resource }) => {
345
345
+
const ResourceCard = ({ resource, isNew }) => {
1824
346
// Function to render stars based on quality rating
1825
347
const renderQualityStars = (quality) => {
1826
348
const stars = [];
···
1845
367
className="resource-card"
1846
368
>
1847
369
<div className="resource-content">
1848
1848
-
<h3 className="resource-name">{resource.name}</h3>
370
370
+
<div className="resource-header">
371
371
+
<h3 className="resource-name">{resource.name}</h3>
372
372
+
{isNew && (
373
373
+
<span className="new-badge">NEW</span>
374
374
+
)}
375
375
+
</div>
1849
376
<p className="resource-description">{resource.description}</p>
1850
377
<p className="resource-domain">{resource.domain}</p>
1851
378
<div className="resource-meta">
+7
src/services/supabaseClient.js
Reviewed
···
1
1
+
// src/lib/supabaseClient.js
2
2
+
import { createClient } from '@supabase/supabase-js';
3
3
+
4
4
+
const supabaseUrl = process.env.REACT_APP_SUPABASE_URL;
5
5
+
const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY;
6
6
+
7
7
+
export const supabase = createClient(supabaseUrl, supabaseAnonKey);