This repository has no description
0

Configure Feed

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

add resources pages and infra

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