This repository has no description
0

Configure Feed

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

at master 25 kB View raw
1// src/components/Resources/Resources.jsx 2import React, { useState, useEffect, useMemo } from 'react'; 3import './Resources.css'; 4import ResourceLoader from './ResourceLoader'; 5import { supabase } from '../../lib/supabase'; 6 7const Resources = () => { 8 // State management 9 const [resources, setResources] = useState([]); 10 const [activeCategory, setActiveCategory] = useState('All'); 11 const [searchQuery, setSearchQuery] = useState(''); 12 const [showNewOnly, setShowNewOnly] = useState(false); 13 const [showScoreImpactOnly, setShowScoreImpactOnly] = useState(false); 14 const [isLoading, setIsLoading] = useState(true); 15 // Add a new state to store category emojis from database 16 const [categoryEmojis, setCategoryEmojis] = useState({ 17 'All': '🗃️' // Default emoji for 'All' 18 }); 19 // New state for random resources 20 const [randomResources, setRandomResources] = useState([]); 21 const [showRandomResources, setShowRandomResources] = useState(false); 22 23 // Load saved user preferences from localStorage 24 useEffect(() => { 25 const savedPreferences = localStorage.getItem('resourcesPreferences'); 26 if (savedPreferences) { 27 try { 28 const preferences = JSON.parse(savedPreferences); 29 setActiveCategory(preferences.activeCategory || 'All'); 30 setShowNewOnly(preferences.showNewOnly || false); 31 setShowScoreImpactOnly(preferences.showScoreImpactOnly || false); 32 } catch (error) { 33 console.error('Error loading preferences:', error); 34 } 35 } 36 }, []); 37 38 // Save user preferences to localStorage 39 useEffect(() => { 40 const preferences = { 41 activeCategory, 42 showNewOnly, 43 showScoreImpactOnly 44 }; 45 localStorage.setItem('resourcesPreferences', JSON.stringify(preferences)); 46 }, [activeCategory, showNewOnly, showScoreImpactOnly]); 47 48 // Hide random resources when filters are applied 49 useEffect(() => { 50 // Hide random resources when any filter is active or category is not 'All' 51 if (showNewOnly || showScoreImpactOnly || activeCategory !== 'All' || searchQuery.trim() !== '') { 52 setShowRandomResources(false); 53 } 54 }, [showNewOnly, showScoreImpactOnly, activeCategory, searchQuery]); 55 56 // Fetch resources from Supabase 57 useEffect(() => { 58 async function fetchResources() { 59 setIsLoading(true); 60 try { 61 // Fetch only published resources 62 const { data: resourcesData, error: resourcesError } = await supabase 63 .from('resources') 64 .select('*') 65 .eq('status', 'published') // Only select resources with 'published' status 66 .order('position'); 67 68 if (resourcesError) { 69 throw resourcesError; 70 } 71 72 // Rest of your existing fetching code continues as before... 73 // Then fetch the categories for each resource using the junction table 74 const { data: resourceCategories, error: categoriesError } = await supabase 75 .from('resource_categories') 76 .select(` 77 resource_id, 78 category:categories(id, name, emoji) 79 `); 80 81 if (categoriesError) { 82 throw categoriesError; 83 } 84 85 // Fetch all categories to build the emoji mapping 86 const { data: allCategories, error: allCategoriesError } = await supabase 87 .from('categories') 88 .select('name, emoji'); 89 90 if (allCategoriesError) { 91 throw allCategoriesError; 92 } 93 94 // Build category emojis mapping 95 const emojisMap = { 'All': '🗃️' }; // Default for 'All' 96 allCategories.forEach(category => { 97 emojisMap[category.name] = category.emoji || '❓'; // Fallback emoji if none in DB 98 }); 99 setCategoryEmojis(emojisMap); 100 101 // Then fetch the tags for each resource 102 const { data: resourceTags, error: tagsError } = await supabase 103 .from('resource_tags') 104 .select(` 105 resource_id, 106 tag:tags(id, name) 107 `); 108 109 if (tagsError) { 110 throw tagsError; 111 } 112 113 // Group categories by resource_id 114 const categoriesByResource = {}; 115 resourceCategories.forEach(item => { 116 if (!categoriesByResource[item.resource_id]) { 117 categoriesByResource[item.resource_id] = []; 118 } 119 categoriesByResource[item.resource_id].push({ 120 id: item.category.id, 121 name: item.category.name, 122 emoji: item.category.emoji || '❓' // Fallback emoji if none in DB 123 }); 124 }); 125 126 // Group tags by resource_id 127 const tagsByResource = {}; 128 resourceTags.forEach(item => { 129 if (!tagsByResource[item.resource_id]) { 130 tagsByResource[item.resource_id] = []; 131 } 132 tagsByResource[item.resource_id].push({ 133 id: item.tag.id, 134 name: item.tag.name 135 }); 136 }); 137 138 // Transform data to match the expected format 139 const formattedResources = resourcesData.map(resource => { 140 // Get categories for this resource 141 const resourceCategoryList = categoriesByResource[resource.id] || []; 142 // Get tags for this resource 143 const resourceTagList = tagsByResource[resource.id] || []; 144 145 return { 146 ...resource, 147 // Primary category for backwards compatibility (use first category if available) 148 category: resourceCategoryList.length > 0 ? resourceCategoryList[0].name : 'Misc', 149 // Store all categories 150 categories: resourceCategoryList, 151 // Store all tags 152 tags: resourceTagList, 153 emoji: resourceCategoryList.length > 0 ? resourceCategoryList[0].emoji : '🔮', 154 url: addUTMParameters(resource.url) 155 }; 156 }); 157 158 setResources(formattedResources); 159 } catch (error) { 160 console.error('Error fetching resources:', error); 161 // In case of error, we could use local data as fallback 162 } finally { 163 setIsLoading(false); 164 } 165 } 166 167 fetchResources(); 168 }, []); 169 170// Check if a resource is new (added in the last 14 days) 171// but exclude resources created on February 27, 2025 172const isNewResource = (date) => { 173 if (!date) return false; 174 175 const resourceDate = new Date(date); 176 177 // Check if the resource was created on February 27, 2025 178 // We need to use UTC methods to avoid timezone issues with database timestamps 179 const isFeb27 = resourceDate.getUTCFullYear() === 2025 && 180 resourceDate.getUTCMonth() === 1 && // February is month 1 (0-indexed) 181 resourceDate.getUTCDate() === 27; 182 183 // If it was created on Feb 27, 2025, don't mark it as new 184 if (isFeb27) { 185 return false; 186 } 187 188 // Otherwise, apply the normal 14-day rule 189 const now = new Date(); 190 const daysDiff = Math.floor((now - resourceDate) / (1000 * 60 * 60 * 24)); 191 return daysDiff < 14; 192}; 193 194 // Check if a resource impacts score 195 const impactsScore = (resource) => { 196 if (!resource.tags) return false; 197 return resource.tags.some(tag => tag.name.toLowerCase() === 'score'); 198 }; 199 200 // Add UTM parameters to URLs 201 const addUTMParameters = (url) => { 202 const separator = url.includes('?') ? '&' : '?'; 203 return `${url}${separator}utm_source=cred.blue&utm_medium=resources&utm_campaign=tools_directory`; 204 }; 205 206 // Function to share the resources page on Bluesky 207 const shareOnBluesky = () => { 208 const shareText = `Check out this collection of Bluesky + ATProto resources curated by @cred.blue! 🔧🦋\n\nFind lexicons, alternative clients, and much more to enhance your Bluesky experience.\n\nExplore the library: https://cred.blue/resources`; 209 210 window.open( 211 `https://bsky.app/intent/compose?text=${encodeURIComponent(shareText)}`, 212 '_blank' 213 ); 214 }; 215 216 // Get 4 random resources from the full resource list 217 const getRandomResources = () => { 218 // Filter out any resources that might not have essential data 219 const validResources = resources.filter(r => r.name && r.description); 220 221 if (validResources.length <= 6) { 222 setRandomResources(validResources); 223 return; 224 } 225 226 // Create a copy of the array to avoid mutating the original 227 const shuffled = [...validResources]; 228 229 // Fisher-Yates shuffle algorithm 230 for (let i = shuffled.length - 1; i > 0; i--) { 231 const j = Math.floor(Math.random() * (i + 1)); 232 [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; 233 } 234 235 // Take the first 6 items 236 setRandomResources(shuffled.slice(0, 6)); 237 setShowRandomResources(true); 238 239 // Reset all filters when showing random resources 240 if (activeCategory !== 'All' || showNewOnly || showScoreImpactOnly || searchQuery.trim() !== '') { 241 setActiveCategory('All'); 242 setShowNewOnly(false); 243 setShowScoreImpactOnly(false); 244 setSearchQuery(''); 245 } 246 247 // Auto-scroll to the random resources section 248 setTimeout(() => { 249 const element = document.getElementById('random-resources-section'); 250 if (element) { 251 element.scrollIntoView({ behavior: 'smooth' }); 252 } 253 }, 100); 254 }; 255 256 // Get all categories from resources 257 const categories = useMemo(() => { 258 if (resources.length === 0) return ['All']; 259 260 // Extract all unique categories from all resources 261 const allCategories = new Set(); 262 resources.forEach(resource => { 263 if (resource.categories && resource.categories.length > 0) { 264 resource.categories.forEach(cat => allCategories.add(cat.name)); 265 } 266 }); 267 268 return ['All', ...Array.from(allCategories).sort()]; 269 }, [resources]); 270 271 // Count resources per category 272 const categoryCounts = useMemo(() => { 273 const counts = { 'All': resources.length }; 274 275 resources.forEach(resource => { 276 if (resource.categories && resource.categories.length > 0) { 277 resource.categories.forEach(category => { 278 counts[category.name] = (counts[category.name] || 0) + 1; 279 }); 280 } 281 }); 282 283 return counts; 284 }, [resources]); 285 286 // Check if a resource belongs to a category 287 const resourceHasCategory = (resource, categoryName) => { 288 if (categoryName === 'All') return true; 289 return resource.categories && resource.categories.some(cat => cat.name === categoryName); 290 }; 291 292// Filter resources based on active category, search query, and filters 293const filteredResources = useMemo(() => { 294 return resources.filter(resource => { 295 // Filter by category 296 const categoryMatch = resourceHasCategory(resource, activeCategory); 297 298 // Filter by search query 299 const searchMatch = 300 resource.name.toLowerCase().includes(searchQuery.toLowerCase()) || 301 resource.description.toLowerCase().includes(searchQuery.toLowerCase()) || 302 (resource.domain && resource.domain.toLowerCase().includes(searchQuery.toLowerCase())) || 303 // Add search in tags 304 (resource.tags && resource.tags.some(tag => 305 tag.name.toLowerCase().includes(searchQuery.toLowerCase()) 306 )); 307 308 // Filter by "new" status if the toggle is active 309 const newMatch = !showNewOnly || isNewResource(resource.created_at); 310 311 // Filter by "impacts score" status if the toggle is active 312 const scoreMatch = !showScoreImpactOnly || impactsScore(resource); 313 314 return categoryMatch && searchMatch && newMatch && scoreMatch; 315 }); 316}, [resources, activeCategory, searchQuery, showNewOnly, showScoreImpactOnly]); 317 318 // Get featured resources 319 const featuredResources = useMemo(() => { 320 return resources.filter(resource => resource.featured); 321 }, [resources]); 322 323 // Group resources by category when "All" is selected and randomize order within each category 324 const resourcesByCategory = useMemo(() => { 325 if (activeCategory !== 'All') return {}; 326 327 const grouped = {}; 328 329 // First, initialize all category groups 330 categories.forEach(category => { 331 if (category !== 'All') { 332 grouped[category] = []; 333 } 334 }); 335 336 // Then add resources to their respective categories 337 filteredResources.forEach(resource => { 338 if (resource.categories && resource.categories.length > 0) { 339 // Add resource to each of its categories 340 resource.categories.forEach(category => { 341 if (!grouped[category.name]) { 342 grouped[category.name] = []; 343 } 344 // Avoid duplicates 345 if (!grouped[category.name].some(r => r.id === resource.id)) { 346 grouped[category.name].push(resource); 347 } 348 }); 349 } else { 350 // If no categories, add to Misc 351 if (!grouped['Misc']) { 352 grouped['Misc'] = []; 353 } 354 grouped['Misc'].push(resource); 355 } 356 }); 357 358 // Remove empty categories 359 Object.keys(grouped).forEach(category => { 360 if (grouped[category].length === 0) { 361 delete grouped[category]; 362 } else { 363 // Randomize order of resources within each category 364 // Use Fisher-Yates shuffle algorithm 365 const array = grouped[category]; 366 for (let i = array.length - 1; i > 0; i--) { 367 const j = Math.floor(Math.random() * (i + 1)); 368 [array[i], array[j]] = [array[j], array[i]]; 369 } 370 } 371 }); 372 373 return grouped; 374 }, [filteredResources, activeCategory, categories]); 375 376 // Randomize the filtered resources order (for specific category views) 377 const randomizedFilteredResources = useMemo(() => { 378 // Only randomize when not in "All" category view 379 if (activeCategory === 'All') return filteredResources; 380 381 // Create a new array to avoid mutating the original 382 const shuffled = [...filteredResources]; 383 384 // Fisher-Yates shuffle algorithm 385 for (let i = shuffled.length - 1; i > 0; i--) { 386 const j = Math.floor(Math.random() * (i + 1)); 387 [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; 388 } 389 390 return shuffled; 391 }, [filteredResources, activeCategory]); 392 393 // Should show featured section only when All category is selected and search query is empty 394 const shouldShowFeatured = activeCategory === 'All' && 395 searchQuery.trim() === '' && 396 !showNewOnly && 397 !showScoreImpactOnly; 398 399 // Handle search input change 400 const handleSearchChange = (e) => { 401 setSearchQuery(e.target.value); 402 }; 403 404 // Handlers for filters to hide random resources 405 const handleCategoryChange = (e) => { 406 setActiveCategory(e.target.value); 407 }; 408 409 const handleNewToggle = () => { 410 setShowNewOnly(!showNewOnly); 411 }; 412 413 const handleScoreToggle = () => { 414 setShowScoreImpactOnly(!showScoreImpactOnly); 415 }; 416 417 return ( 418 <main className="resources-page"> 419 <div className="alt-card"> 420 {/* Redesigned Header Section */} 421 <header className="resources-header"> 422 <div className="header-main"> 423 <h1>Bluesky & AT Protocol Resources</h1> 424 <div className="header-tagline"> 425 <p className="header-tagline-p">A curated collection of tools and services for the Bluesky ecosystem</p> 426 <p className="header-tagline-detail">To submit a resource, DM @cred.blue</p> 427 </div> 428 </div> 429 430 <div className="search-filters-container"> 431 <div className="search-container"> 432 <input 433 type="text" 434 placeholder="Search resources..." 435 value={searchQuery} 436 onChange={handleSearchChange} 437 className="search-input" 438 aria-label="Search resources" 439 /> 440 </div> 441 442 <div className="quick-actions"> 443 <button 444 className="feeling-lucky-button" 445 type="button" 446 onClick={getRandomResources} 447 aria-label="Show random resources" 448 > 449 Feeling Lucky 450 </button> 451 </div> 452 </div> 453 </header> 454 455 <div className="filter-controls-container"> 456 {/* Improved Filter Bar */} 457 <div className="filter-bar"> 458 <div className="filter-section"> 459 {/* All filters in one row */} 460 <div className="filters-row"> 461 {/* Category filter dropdown */} 462 <div className="filter-dropdown"> 463 <label htmlFor="category-select" className="filter-label">Category:</label> 464 <select 465 id="category-select" 466 value={activeCategory} 467 onChange={handleCategoryChange} 468 className="filter-select" 469 > 470 {categories.map(category => ( 471 <option key={category} value={category}> 472 {categoryEmojis[category] || '🔹'} {category} ({categoryCounts[category] || 0}) 473 </option> 474 ))} 475 </select> 476 </div> 477 478 {/* Toggle filters */} 479 <div className="toggle-filters"> 480 {/* New resources toggle */} 481 <div className="toggle-filter"> 482 <label className="toggle-label" htmlFor="new-toggle"> 483 <input 484 id="new-toggle" 485 type="checkbox" 486 checked={showNewOnly} 487 onChange={handleNewToggle} 488 aria-label="Show only recently added resources" 489 /> 490 <span className="toggle-text">Recently Added</span> 491 </label> 492 </div> 493 494 {/* Score impact toggle */} 495 <div className="toggle-filter"> 496 <label className="toggle-label" htmlFor="score-toggle"> 497 <input 498 id="score-toggle" 499 type="checkbox" 500 checked={showScoreImpactOnly} 501 onChange={handleScoreToggle} 502 aria-label="Show only resources that impact score" 503 /> 504 <span className="toggle-text">Impacts Score</span> 505 </label> 506 </div> 507 </div> 508 509 {/* Share button (moved to filter row) */} 510 <div className="filter-share-button"> 511 <button 512 className="share-button compact" 513 type="button" 514 onClick={shareOnBluesky} 515 aria-label="Share this page on Bluesky" 516 > 517 Share 518 </button> 519 </div> 520 </div> 521 </div> 522 </div> 523 524 <div className="resources-disclaimer"> 525 <div className="disclaimer-icon"></div> 526 <p><strong>Disclaimer:</strong> These resources are not affiliated with cred.blue or Bluesky. Use them at your own risk and exercise caution when providing access to your data.</p> 527 </div> 528 </div> 529 530 {/* Loading indication */} 531 {isLoading ? ( 532 <ResourceLoader /> 533 ) : ( 534 <> 535 {/* Random Resources Section - now only shows when no filters are active */} 536 {showRandomResources && randomResources.length > 0 && !showNewOnly && !showScoreImpactOnly && activeCategory === 'All' && searchQuery.trim() === '' && ( 537 <div id="random-resources-section" className="random-resources-section"> 538 <h2>Feeling Lucky Results</h2> 539 <p className="featured-description">Here are {randomResources.length} resources picked just for you!</p> 540 <div className="resources-grid"> 541 {randomResources.map((resource, index) => ( 542 <ResourceCard 543 key={`random-${index}`} 544 resource={resource} 545 isNew={isNewResource(resource.created_at)} 546 impactsScore={impactsScore(resource)} 547 /> 548 ))} 549 </div> 550 </div> 551 )} 552 553 {/* Featured Section - Hidden when quality filter is active or search query is not empty */} 554 {shouldShowFeatured && featuredResources.length > 0 && ( 555 <div className="featured-section"> 556 <h2>Featured Resources</h2> 557 <p className="featured-description">Hand-selected tools that we love and use regularly. These are not sponsored or paid placements.</p> 558 <div className="resources-grid"> 559 {featuredResources.map((resource, index) => ( 560 <ResourceCard 561 key={`featured-${index}`} 562 resource={resource} 563 isNew={isNewResource(resource.created_at)} 564 impactsScore={impactsScore(resource)} 565 /> 566 ))} 567 </div> 568 </div> 569 )} 570 571 {activeCategory === 'All' ? ( 572 // When "All" is selected, show resources by category 573 <div className="all-resources-section"> 574 <h2>All Resources ({filteredResources.length})</h2> 575 576 {Object.keys(resourcesByCategory).sort().map(category => ( 577 <div key={category} className="category-section"> 578 <h3 className="category-header"> 579 {categoryEmojis[category] || '❓'} {category} ({resourcesByCategory[category].length}) 580 </h3> 581 <div className="resources-grid"> 582 {resourcesByCategory[category].map((resource, index) => ( 583 <ResourceCard 584 key={`${category}-${index}`} 585 resource={resource} 586 isNew={isNewResource(resource.created_at)} 587 impactsScore={impactsScore(resource)} 588 /> 589 ))} 590 </div> 591 </div> 592 ))} 593 </div> 594 ) : ( 595 // When a specific category is selected 596 <div className="all-resources-section"> 597 <h2>{categoryEmojis[activeCategory] || '❓'} {activeCategory} Resources ({filteredResources.length})</h2> 598 {filteredResources.length > 0 ? ( 599 <div className="resources-grid"> 600 {randomizedFilteredResources.map((resource, index) => ( 601 <ResourceCard 602 key={index} 603 resource={resource} 604 isNew={isNewResource(resource.created_at)} 605 impactsScore={impactsScore(resource)} 606 /> 607 ))} 608 </div> 609 ) : ( 610 <div className="no-results"> 611 <p>No resources found matching your filters.</p> 612 </div> 613 )} 614 </div> 615 )} 616 </> 617 )} 618 </div> 619 </main> 620 ); 621}; 622 623// ResourceCard component for displaying individual resources 624const ResourceCard = ({ resource, isNew, impactsScore }) => { 625 return ( 626 <a 627 href={resource.url} 628 target="_blank" 629 rel="noopener noreferrer" 630 className="resource-card" 631 > 632 <div className="resource-content"> 633 <div className="resource-header"> 634 <h3 className="resource-name">{resource.name}</h3> 635 <div className="resource-badges"> 636 {isNew && ( 637 <span className="new-badge">NEW</span> 638 )} 639 {impactsScore && ( 640 <span className="score-badge">SCORE</span> 641 )} 642 </div> 643 </div> 644 <p className="resource-description">{resource.description}</p> 645 <p className="resource-domain">{resource.domain}</p> 646 <div className="resource-meta"> 647 <div className="resource-categories"> 648 {resource.categories && resource.categories.length > 0 ? ( 649 resource.categories.map((cat, idx) => ( 650 <span key={idx} className="resource-category"> 651 {cat.name} 652 </span> 653 )) 654 ) : ( 655 <span className="resource-category">Misc</span> 656 )} 657 </div> 658 </div> 659 {resource.tags && resource.tags.length > 0 && ( 660 <div className="resource-tags"> 661 {resource.tags.map((tag, idx) => ( 662 <span key={idx} className="resource-tag"> 663 #{tag.name} 664 </span> 665 ))} 666 </div> 667 )} 668 </div> 669 </a> 670 ); 671}; 672 673export default Resources;