This repository has no description
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;