···
42
42
<Route path="/leaderboard" element={<Leaderboard />} />
43
43
<Route path="/resources" element={<Resources />} />
44
44
<Route path="/resources/submit" element={<ResourceSubmission />} />
45
45
-
<Route path="/admin/resources" element={<ResourcesManager />} />
46
45
<Route path="/shortcut" element={<Shortcut />} />
47
46
<Route path="/zen" element={<ZenPage />} />
48
47
<Route path="/methodology" element={<ScoringMethodology />} />
···
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
-
}
···
1
1
-
// src/components/Admin/ResourcesManager.jsx
2
2
-
import React, { useState, useEffect } from 'react';
3
3
-
import { supabase } from '../../lib/supabase';
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;