This repository has no description
0

Configure Feed

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

Enhance CollectionsFeed component: Added new styles for improved UI, including search functionality and error handling. Implemented loading indicators, user authentication warnings, and refined data fetching logic for collections and records.

+673 -434
+314 -1
src/components/CollectionsFeed/CollectionsFeed.css
··· 867 867 font-size: 0.9rem; 868 868 line-height: 1.3; 869 869 } 870 - } 870 + } 871 + 872 + /* Add these new styles for the improved UI */ 873 + 874 + .search-container { 875 + max-width: 1200px; 876 + margin: 0 auto; 877 + padding: 20px; 878 + } 879 + 880 + .feed-description { 881 + margin-bottom: 20px; 882 + color: #666; 883 + font-size: 1rem; 884 + } 885 + 886 + .auth-warning { 887 + background-color: #fff4e5; 888 + border-left: 4px solid #ff9800; 889 + padding: 12px 16px; 890 + margin-bottom: 20px; 891 + border-radius: 4px; 892 + } 893 + 894 + .auth-warning p { 895 + margin: 0; 896 + color: #884400; 897 + } 898 + 899 + .search-form { 900 + margin-bottom: 20px; 901 + } 902 + 903 + .search-box { 904 + display: flex; 905 + gap: 10px; 906 + } 907 + 908 + .search-box input { 909 + flex: 1; 910 + padding: 10px 15px; 911 + border: 1px solid #ddd; 912 + border-radius: 4px; 913 + font-size: 1rem; 914 + } 915 + 916 + .search-box button { 917 + padding: 10px 20px; 918 + background-color: #3498db; 919 + color: white; 920 + border: none; 921 + border-radius: 4px; 922 + cursor: pointer; 923 + font-size: 1rem; 924 + font-weight: 500; 925 + } 926 + 927 + .search-box button:hover { 928 + background-color: #2980b9; 929 + } 930 + 931 + .search-box button:disabled { 932 + background-color: #95a5a6; 933 + cursor: not-allowed; 934 + } 935 + 936 + .error-message { 937 + background-color: #fee; 938 + border-left: 4px solid #e74c3c; 939 + padding: 12px 16px; 940 + margin-bottom: 20px; 941 + border-radius: 4px; 942 + } 943 + 944 + .error-message p { 945 + margin: 0 0 10px 0; 946 + color: #c0392b; 947 + } 948 + 949 + .retry-button { 950 + background-color: #e74c3c; 951 + color: white; 952 + border: none; 953 + padding: 6px 12px; 954 + border-radius: 4px; 955 + cursor: pointer; 956 + font-size: 0.9rem; 957 + } 958 + 959 + .retry-button:hover { 960 + background-color: #c0392b; 961 + } 962 + 963 + .loading-indicator { 964 + display: flex; 965 + flex-direction: column; 966 + align-items: center; 967 + justify-content: center; 968 + padding: 40px 0; 969 + } 970 + 971 + .spinner { 972 + border: 4px solid rgba(0, 0, 0, 0.1); 973 + border-radius: 50%; 974 + border-top: 4px solid #3498db; 975 + width: 40px; 976 + height: 40px; 977 + animation: spin 1s linear infinite; 978 + margin-bottom: 20px; 979 + } 980 + 981 + @keyframes spin { 982 + 0% { transform: rotate(0deg); } 983 + 100% { transform: rotate(360deg); } 984 + } 985 + 986 + .user-info { 987 + background-color: #fff; 988 + border-radius: 8px; 989 + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 990 + padding: 20px; 991 + } 992 + 993 + .user-info h2 { 994 + margin-top: 0; 995 + border-bottom: 1px solid #eee; 996 + padding-bottom: 10px; 997 + } 998 + 999 + .user-info .handle { 1000 + font-weight: normal; 1001 + color: #666; 1002 + margin-left: 8px; 1003 + font-size: 0.9em; 1004 + } 1005 + 1006 + .collections-meta { 1007 + display: flex; 1008 + justify-content: space-between; 1009 + align-items: center; 1010 + margin-bottom: 20px; 1011 + } 1012 + 1013 + .time-toggle { 1014 + display: flex; 1015 + align-items: center; 1016 + } 1017 + 1018 + .info-tooltip { 1019 + position: relative; 1020 + display: inline-block; 1021 + width: 18px; 1022 + height: 18px; 1023 + background-color: #3498db; 1024 + color: white; 1025 + border-radius: 50%; 1026 + text-align: center; 1027 + margin-left: 8px; 1028 + font-size: 12px; 1029 + cursor: pointer; 1030 + } 1031 + 1032 + .tooltip-text { 1033 + visibility: hidden; 1034 + width: 250px; 1035 + background-color: #333; 1036 + color: #fff; 1037 + text-align: center; 1038 + border-radius: 6px; 1039 + padding: 8px; 1040 + position: absolute; 1041 + z-index: 1; 1042 + bottom: 125%; 1043 + left: 50%; 1044 + transform: translateX(-50%); 1045 + opacity: 0; 1046 + transition: opacity 0.3s; 1047 + font-size: 0.8rem; 1048 + } 1049 + 1050 + .info-tooltip:hover .tooltip-text { 1051 + visibility: visible; 1052 + opacity: 1; 1053 + } 1054 + 1055 + .collections-filter { 1056 + margin-bottom: 20px; 1057 + background-color: #f9f9f9; 1058 + border-radius: 6px; 1059 + padding: 15px; 1060 + } 1061 + 1062 + .collections-filter h3 { 1063 + margin-top: 0; 1064 + margin-bottom: 10px; 1065 + } 1066 + 1067 + .filter-actions { 1068 + display: flex; 1069 + gap: 10px; 1070 + margin-bottom: 15px; 1071 + } 1072 + 1073 + .filter-actions button { 1074 + padding: 6px 12px; 1075 + border: none; 1076 + border-radius: 4px; 1077 + background-color: #f1f1f1; 1078 + cursor: pointer; 1079 + font-size: 0.9rem; 1080 + } 1081 + 1082 + .filter-actions button:hover { 1083 + background-color: #e5e5e5; 1084 + } 1085 + 1086 + .filter-actions button.refresh-button { 1087 + margin-left: auto; 1088 + background-color: #2ecc71; 1089 + color: white; 1090 + } 1091 + 1092 + .filter-actions button.refresh-button:hover { 1093 + background-color: #27ae60; 1094 + } 1095 + 1096 + .collections-list { 1097 + display: grid; 1098 + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 1099 + gap: 10px; 1100 + max-height: 300px; 1101 + overflow-y: auto; 1102 + } 1103 + 1104 + .collection-item { 1105 + padding: 6px 10px; 1106 + background-color: #fff; 1107 + border-radius: 4px; 1108 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 1109 + } 1110 + 1111 + .collection-item label { 1112 + display: flex; 1113 + align-items: center; 1114 + cursor: pointer; 1115 + font-size: 0.9rem; 1116 + } 1117 + 1118 + .collection-item input { 1119 + margin-right: 8px; 1120 + } 1121 + 1122 + .chart-loading { 1123 + display: flex; 1124 + flex-direction: column; 1125 + align-items: center; 1126 + padding: 30px 0; 1127 + background-color: #f9f9f9; 1128 + border-radius: 6px; 1129 + margin-bottom: 20px; 1130 + } 1131 + 1132 + .chart-container { 1133 + margin-bottom: 20px; 1134 + } 1135 + 1136 + .feed-heading { 1137 + border-bottom: 1px solid #eee; 1138 + padding-bottom: 8px; 1139 + margin-top: 30px; 1140 + } 1141 + 1142 + .no-records-message { 1143 + padding: 20px; 1144 + text-align: center; 1145 + color: #666; 1146 + background-color: #f9f9f9; 1147 + border-radius: 6px; 1148 + margin-top: 10px; 1149 + } 1150 + 1151 + .no-collections-selected { 1152 + padding: 20px; 1153 + text-align: center; 1154 + color: #666; 1155 + background-color: #f9f9f9; 1156 + border-radius: 6px; 1157 + margin-top: 20px; 1158 + } 1159 + 1160 + .load-more-container { 1161 + display: flex; 1162 + justify-content: center; 1163 + margin: 20px 0; 1164 + } 1165 + 1166 + .load-more-button { 1167 + padding: 10px 20px; 1168 + background-color: #3498db; 1169 + color: white; 1170 + border: none; 1171 + border-radius: 4px; 1172 + cursor: pointer; 1173 + font-size: 1rem; 1174 + } 1175 + 1176 + .load-more-button:hover { 1177 + background-color: #2980b9; 1178 + } 1179 + 1180 + .load-more-button:disabled { 1181 + background-color: #95a5a6; 1182 + cursor: not-allowed; 1183 + }
+359 -433
src/components/CollectionsFeed/CollectionsFeed.js
··· 14 14 const navigate = useNavigate(); 15 15 const { isAuthenticated, checkAuthStatus } = useAuth(); 16 16 17 - // State variables 17 + // Initialize state variables 18 18 const [handle, setHandle] = useState(username || ''); 19 + const [searchTerm, setSearchTerm] = useState(username || ''); 19 20 const [displayName, setDisplayName] = useState(''); 20 21 const [did, setDid] = useState(''); 21 22 const [serviceEndpoint, setServiceEndpoint] = useState(''); ··· 104 105 // Define fetchCollectionRecords with useCallback first 105 106 const fetchCollectionRecords = useCallback(async (userDid, endpoint, collectionsList, isLoadMore = false) => { 106 107 try { 107 - setFetchingMore(isLoadMore); 108 - 109 - // Set chartLoading for initial load or when refreshing 110 - if (!isLoadMore || collectionsList.length > 0) { 108 + if (!isLoadMore) { 111 109 setChartLoading(true); 112 110 } 113 111 114 - // Arrays to store all fetched records 115 - let allRecords = isLoadMore ? [...records] : []; 116 - let allChartRecords = isLoadMore ? [...allRecordsForChart] : []; 112 + // Skip if no collections selected 113 + if (!collectionsList || collectionsList.length === 0) { 114 + console.log('No collections to fetch'); 115 + setFetchingMore(false); 116 + setChartLoading(false); 117 + return; 118 + } 119 + 120 + console.log(`Fetching records for ${collectionsList.length} collections:`, collectionsList); 121 + 122 + // Create a copy of the cursors 117 123 const newCursors = { ...collectionCursors }; 118 124 119 - // Calculate a cutoff date (90 days ago by default for chart visualization) 120 - const cutoffDate = new Date(); 121 - cutoffDate.setDate(cutoffDate.getDate() - 90); // 3 months 125 + let allRecords = []; 126 + let allChartRecords = []; 122 127 123 - // Flag to track if we're doing a deep initial load for chart data 128 + // Check if we need full history for the initial deep load 124 129 const isInitialDeepLoad = !isLoadMore && allRecordsForChart.length === 0; 125 130 126 131 // Sequential processing for each collection to avoid overloading the API ··· 145 150 url += `&cursor=${encodeURIComponent(cursor)}`; 146 151 } 147 152 148 - const response = await fetch(url, { 149 - credentials: 'include' 150 - }); 151 - 152 - if (!response.ok) { 153 - if (response.status === 401) { 154 - // Handle unauthorized 155 - checkAuthStatus(); 156 - throw new Error('Authentication required. Please log in again.'); 157 - } 158 - console.error(`Error fetching records for ${collection}: ${response.statusText}`); 159 - break; 160 - } 161 - 162 - const data = await response.json(); 163 - pageCount++; 164 - 165 - // Process records from this page 166 - if (data.records && data.records.length > 0) { 167 - const processedRecords = data.records.map(record => { 168 - const contentTimestamp = extractTimestamp(record); 169 - const rkey = record.uri.split('/').pop(); 170 - const rkeyTimestamp = tidToTimestamp(rkey); 171 - 172 - return { 173 - ...record, 174 - collection, 175 - collectionType: record.value?.$type || collection, 176 - contentTimestamp, 177 - rkeyTimestamp, 178 - rkey, 179 - }; 153 + try { 154 + // Verify authentication before making the request 155 + await checkAuthStatus(); 156 + 157 + const response = await fetch(url, { 158 + credentials: 'include' 180 159 }); 181 160 182 - // For deep load, check if we've reached records beyond our cutoff 183 - if (isInitialDeepLoad) { 184 - const oldestRecordTime = processedRecords.reduce((oldest, record) => { 185 - const timestamp = record.contentTimestamp || record.rkeyTimestamp; 186 - if (!timestamp) return oldest; 187 - 188 - const recordTime = new Date(timestamp).getTime(); 189 - return recordTime < oldest ? recordTime : oldest; 190 - }, Date.now()); 161 + if (!response.ok) { 162 + if (response.status === 401) { 163 + // Handle unauthorized 164 + await checkAuthStatus(); 165 + if (!isAuthenticated) { 166 + throw new Error('Authentication required. Please log in again.'); 167 + } 168 + // If we're still authenticated, retry this request 169 + continue; 170 + } 191 171 192 - // For commonly used collections like likes, follows, etc., 193 - // we need to be more cautious about when to stop paginating 194 - const isHighVolumeCollection = collection.includes('like') || 195 - collection.includes('follow') || 196 - collection.includes('repost'); 172 + // Try to parse the error response 173 + const errorData = await response.json().catch(() => null); 174 + const errorMessage = errorData?.error || errorData?.details || response.statusText; 175 + console.error(`Error fetching records for ${collection}: ${errorMessage}`); 176 + 177 + // Skip this collection but continue with others 178 + break; 179 + } 180 + 181 + const data = await response.json(); 182 + pageCount++; 183 + 184 + // Process each record to extract timestamps 185 + if (data.records && data.records.length > 0) { 186 + // Process and add timestamps to records 187 + const processedRecords = data.records.map(record => { 188 + const contentTimestamp = extractTimestamp(record); 189 + const rkey = record.uri.split('/').pop(); 190 + const rkeyTimestamp = tidToTimestamp(rkey); 197 191 198 - // If the oldest record on this page is older than our cutoff, and 199 - // 1. It's not a high volume collection, OR 200 - // 2. It's a high volume collection but we've already gone through several pages 201 - if (oldestRecordTime < cutoffDate.getTime() && 202 - (!isHighVolumeCollection || pageCount > 5)) { 203 - reachedCutoff = true; 204 - console.log(`Reached cutoff date for ${collection} on page ${pageCount} (oldest: ${new Date(oldestRecordTime).toISOString()})`); 192 + return { 193 + ...record, 194 + collection, 195 + collectionType: record.value?.$type || collection, 196 + contentTimestamp, 197 + rkeyTimestamp, 198 + rkey, 199 + }; 200 + }); 201 + 202 + // Add records to our collection records array 203 + collectionRecords = [...collectionRecords, ...processedRecords]; 204 + 205 + // Check if we need to continue fetching more pages for this collection 206 + if (data.cursor && isInitialDeepLoad) { 207 + cursor = data.cursor; 205 208 206 - // Filter records from this page to only include those after cutoff 207 - const filteredRecords = processedRecords.filter(record => { 208 - const timestamp = record.contentTimestamp || record.rkeyTimestamp; 209 - if (!timestamp) return false; 210 - return new Date(timestamp) >= cutoffDate; 211 - }); 209 + // Check if we've reached our history cutoff date 210 + const oldestRecord = processedRecords[processedRecords.length - 1]; 211 + const timestamp = useRkeyTimestamp ? oldestRecord.rkeyTimestamp : oldestRecord.contentTimestamp; 212 212 213 - console.log(` - Kept ${filteredRecords.length} of ${processedRecords.length} records from final page`); 214 - collectionRecords.push(...filteredRecords); 213 + if (timestamp) { 214 + const recordDate = new Date(timestamp); 215 + const cutoffDate = new Date(); 216 + cutoffDate.setDate(cutoffDate.getDate() - 90); // 90 days ago 217 + 218 + if (recordDate < cutoffDate) { 219 + console.log(`Reached cutoff date for ${collection}, stopping pagination`); 220 + reachedCutoff = true; 221 + } 222 + } 215 223 } else { 216 - // All records on this page are within our date range 217 - // OR we need to keep paginating through high-volume collections 218 - collectionRecords.push(...processedRecords); 224 + hasMoreRecords = false; 219 225 } 220 226 } else { 221 - // For regular browsing, include all records from the page 222 - collectionRecords.push(...processedRecords); 227 + hasMoreRecords = false; 223 228 } 224 - } 225 - 226 - // Check if there are more pages 227 - if (data.cursor) { 228 - cursor = data.cursor; 229 - } else { 230 - hasMoreRecords = false; 231 - } 232 - 233 - // If we're not doing deep historical loading, stop after first page 234 - if (!isInitialDeepLoad) { 229 + 230 + // Store the cursor for this collection for future "load more" operations 231 + newCursors[collection] = data.cursor; 232 + } catch (err) { 233 + console.error(`Error fetching page ${pageCount} for collection ${collection}:`, err); 234 + // Break the pagination loop for this collection, but continue with others 235 235 break; 236 236 } 237 - } 238 - 239 - // Save the cursor for this collection for future pagination 240 - if (cursor) { 241 - newCursors[collection] = cursor; 242 - } else { 243 - // No more records for this collection 244 - delete newCursors[collection]; 245 - } 237 + } // End of pagination while loop 246 238 247 - // Add records to appropriate arrays 239 + // Add all records from this collection to our full records arrays 248 240 allChartRecords = [...allChartRecords, ...collectionRecords]; 249 241 250 242 // For display timeline, we might want to be more selective 251 243 if (isLoadMore || !isInitialDeepLoad) { 252 244 allRecords = [...allRecords, ...collectionRecords]; 253 245 } 246 + } // End of collections for loop 247 + 248 + // If we didn't get any records, set an error 249 + if (allChartRecords.length === 0 && !isLoadMore) { 250 + setError('No records found for the selected collections.'); 251 + } else if (isLoadMore && allRecords.length === 0) { 252 + setError('No more records available.'); 253 + } else { 254 + // Clear any previous error since we got records 255 + setError(''); 254 256 } 255 257 256 258 // Filter and sort records based on selected timestamp source ··· 320 322 321 323 } catch (err) { 322 324 console.error('Error fetching collection records:', err); 323 - setError('Failed to fetch records. Please try again.'); 325 + setError('Failed to fetch records. ' + (err.message || 'Please try again.')); 324 326 setFetchingMore(false); 325 327 setChartLoading(false); // Always reset on error 326 328 throw err; // Re-throw to allow handling in calling functions 327 329 } 328 - }, [records, allRecordsForChart, collectionCursors, useRkeyTimestamp, checkAuthStatus]); 330 + }, [records, allRecordsForChart, collectionCursors, useRkeyTimestamp, checkAuthStatus, isAuthenticated]); 329 331 330 332 // Now define loadUserData after fetchCollectionRecords is defined 331 333 const loadUserData = useCallback(async (userHandle) => { ··· 339 341 } 340 342 341 343 // Resolve handle to DID 342 - const userDid = await resolveHandleToDid(userHandle); 343 - setDid(userDid); 344 + let userDid; 345 + try { 346 + userDid = await resolveHandleToDid(userHandle); 347 + setDid(userDid); 348 + } catch (resolveError) { 349 + console.error('Error resolving handle to DID:', resolveError); 350 + setError(`Could not resolve handle "${userHandle}". Please check the handle and try again.`); 351 + setInitialLoad(false); 352 + setLoading(false); 353 + return; 354 + } 344 355 345 356 // Get service endpoint 346 - const endpoint = await getServiceEndpointForDid(userDid); 347 - setServiceEndpoint(endpoint); 357 + let endpoint; 358 + try { 359 + endpoint = await getServiceEndpointForDid(userDid); 360 + setServiceEndpoint(endpoint); 361 + } catch (endpointError) { 362 + console.error('Error getting service endpoint:', endpointError); 363 + setError(`Could not determine PDS endpoint for "${userHandle}". The user's server may be offline.`); 364 + setInitialLoad(false); 365 + setLoading(false); 366 + return; 367 + } 348 368 349 369 // Fetch profile information 350 - const publicApiEndpoint = "https://public.api.bsky.app"; 351 - const profileResponse = await fetch(`${publicApiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(userDid)}`); 352 - 353 - if (!profileResponse.ok) { 354 - throw new Error(`Error fetching profile: ${profileResponse.statusText}`); 370 + try { 371 + const publicApiEndpoint = "https://public.api.bsky.app"; 372 + const profileResponse = await fetch(`${publicApiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(userDid)}`); 373 + 374 + if (!profileResponse.ok) { 375 + throw new Error(`Error fetching profile: ${profileResponse.statusText}`); 376 + } 377 + 378 + const profileData = await profileResponse.json(); 379 + setHandle(profileData.handle); 380 + setDisplayName(profileData.displayName || profileData.handle); 381 + } catch (profileError) { 382 + console.error('Error fetching profile:', profileError); 383 + // Continue without profile data, not critical 355 384 } 356 385 357 - const profileData = await profileResponse.json(); 358 - setHandle(profileData.handle); 359 - setDisplayName(profileData.displayName || profileData.handle); 360 - 361 386 // Use our server-side API to fetch collections 362 - const collectionsResponse = await fetch(`/api/collections/${encodeURIComponent(userDid)}?endpoint=${encodeURIComponent(endpoint)}`, { 363 - credentials: 'include' 364 - }); 387 + let retryCount = 0; 388 + const maxRetries = 2; 389 + let collectionsData; 365 390 366 - if (!collectionsResponse.ok) { 367 - if (collectionsResponse.status === 401) { 368 - // Handle unauthorized 369 - checkAuthStatus(); 370 - throw new Error('Authentication required. Please log in again.'); 391 + while (retryCount <= maxRetries) { 392 + try { 393 + // Verify authentication before making the request 394 + await checkAuthStatus(); 395 + 396 + const collectionsResponse = await fetch(`/api/collections/${encodeURIComponent(userDid)}?endpoint=${encodeURIComponent(endpoint)}`, { 397 + credentials: 'include' 398 + }); 399 + 400 + if (!collectionsResponse.ok) { 401 + if (collectionsResponse.status === 401) { 402 + // Handle unauthorized 403 + console.log('Authentication required, checking status and redirecting if needed'); 404 + await checkAuthStatus(); 405 + if (!isAuthenticated) { 406 + throw new Error('Authentication required. Please log in again.'); 407 + } 408 + // If we're still here, try again 409 + retryCount++; 410 + continue; 411 + } 412 + 413 + // Try to parse the error response 414 + const errorData = await collectionsResponse.json().catch(() => null); 415 + const errorMessage = errorData?.error || errorData?.details || collectionsResponse.statusText; 416 + throw new Error(`Error fetching collections: ${errorMessage}`); 417 + } 418 + 419 + collectionsData = await collectionsResponse.json(); 420 + break; // Success, exit the retry loop 421 + } catch (err) { 422 + console.error(`Attempt ${retryCount + 1} failed:`, err); 423 + if (retryCount === maxRetries) { 424 + // This was our last attempt, propagate the error 425 + throw err; 426 + } 427 + // Wait before retrying 428 + await new Promise(resolve => setTimeout(resolve, 1000 * (retryCount + 1))); 429 + retryCount++; 371 430 } 372 - throw new Error(`Error fetching collections: ${collectionsResponse.statusText}`); 373 431 } 374 - 375 - const collectionsData = await collectionsResponse.json(); 376 432 377 433 if (collectionsData.collections && collectionsData.collections.length > 0) { 378 434 const sortedCollections = [...collectionsData.collections].sort(); ··· 381 437 setSelectedCollections(sortedCollections); 382 438 383 439 // Fetch records for each collection 384 - await fetchCollectionRecords(userDid, endpoint, sortedCollections); 440 + try { 441 + await fetchCollectionRecords(userDid, endpoint, sortedCollections); 442 + } catch (recordsError) { 443 + console.error('Error fetching collection records:', recordsError); 444 + setError(`Successfully loaded collections, but could not load records: ${recordsError.message}`); 445 + // Continue with the collections we have 446 + } 385 447 } else { 386 448 setError('No collections found for this user.'); 387 449 } ··· 395 457 setInitialLoad(false); 396 458 setLoading(false); 397 459 } 398 - }, [username, navigate, checkAuthStatus, fetchCollectionRecords]); 460 + }, [username, navigate, checkAuthStatus, fetchCollectionRecords, isAuthenticated]); 399 461 400 462 // Now place useEffects after all the callbacks are defined 401 463 ··· 643 705 ); 644 706 const canLoadMore = hasMoreRecordsLocally || hasMoreRecordsRemotely; 645 707 708 + // Add the handleSubmit function 709 + const handleSubmit = (e) => { 710 + e.preventDefault(); 711 + if (searchTerm.trim() !== '') { 712 + loadUserData(searchTerm.trim()); 713 + } 714 + }; 715 + 646 716 return ( 647 717 <div className="collections-feed-container"> 648 718 <Helmet> ··· 650 720 <meta name="description" content={username ? `View ${username}'s AT Protocol collection records in chronological order` : 'View AT Protocol collection records in chronological order'} /> 651 721 </Helmet> 652 722 653 - {initialLoad && !username ? ( 654 - <div className="omni-card alt-card"> 655 - <h1>Bluesky Omnifeed</h1> 656 - <p> 657 - View and analyze any Bluesky account's AT Protocol collection records chronologically. 658 - </p> 659 - <form className="search-bar" onSubmit={(e) => { 660 - e.preventDefault(); 661 - if (handle.trim() !== "") { 662 - loadUserData(handle.trim()); 663 - } 664 - }} role="search"> 665 - <div> 666 - <input 667 - type="text" 668 - placeholder="(e.g. user.bsky.social)" 669 - value={handle} 670 - onChange={(e) => setHandle(e.target.value)} 671 - required 672 - /> 673 - </div> 674 - <div className="action-row"> 675 - <button className="analyze-button" type="submit">Analyze</button> 676 - </div> 677 - </form> 678 - 679 - <div className="omni-info-card"> 680 - <h3>What is Omnifeed?</h3> 681 - <p>Omnifeed provides a chronological view of a user's entire ATProto repository, including all collections such as posts, likes, follows, and more. It helps you analyze account history and activity patterns.</p> 723 + <div className="search-container"> 724 + <h1>OmniFeed</h1> 725 + <p className="feed-description"> 726 + View all repository collections for a Bluesky user, including custom collections from AT Protocol apps. 727 + </p> 728 + 729 + {/* Authentication status banner */} 730 + {!isAuthenticated && ( 731 + <div className="auth-warning"> 732 + <p> 733 + <strong>Authentication Required:</strong> You need to be logged in to view the OmniFeed. 734 + Redirecting to login... 735 + </p> 682 736 </div> 683 - </div> 684 - ) : ( 685 - <> 686 - {loading && !fetchingMore ? ( 687 - <div className="loading-container"> 688 - <MatterLoadingAnimation /> 689 - </div> 690 - ) : error ? ( 691 - <div className="error-container"> 692 - <h2>Error</h2> 693 - <p className="error-message">{error}</p> 737 + )} 738 + 739 + <form onSubmit={handleSubmit} className="search-form"> 740 + <div className="search-box"> 741 + <input 742 + type="text" 743 + placeholder="Enter a Bluesky handle (e.g. cred.blue)" 744 + value={searchTerm} 745 + onChange={(e) => setSearchTerm(e.target.value)} 746 + disabled={loading} 747 + /> 748 + <button 749 + type="submit" 750 + disabled={loading || !searchTerm || searchTerm.trim() === ''} 751 + > 752 + {loading ? 'Loading...' : 'Search'} 753 + </button> 754 + </div> 755 + </form> 756 + 757 + {/* Error message with retry option */} 758 + {error && ( 759 + <div className="error-message"> 760 + <p><strong>Error:</strong> {error}</p> 761 + {did && serviceEndpoint && ( 694 762 <button 695 - className="try-again-button" 696 - onClick={() => navigate('/omnifeed')} 763 + onClick={() => loadUserData(handle || searchTerm)} 764 + className="retry-button" 697 765 > 698 - Try Another Account 766 + Retry 699 767 </button> 700 - </div> 701 - ) : searchPerformed && ( 702 - <div className="feed-container"> 703 - <div className="page-title"> 704 - <h1>Omnifeed</h1> 705 - </div> 706 - <div className="user-header"> 707 - <h1>{displayName}</h1> 708 - <h2>@{handle}</h2> 709 - {did && ( 710 - <div className="user-did"> 711 - <span>DID: {did}</span> 712 - <button 713 - className="copy-button" 714 - onClick={(event) => { 715 - navigator.clipboard.writeText(did); 716 - // Show temporary success message 717 - const button = event.currentTarget; 718 - button.classList.add('copied'); 719 - setTimeout(() => button.classList.remove('copied'), 2000); 720 - }} 721 - title="Copy DID" 722 - > 723 - <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 724 - <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> 725 - <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> 726 - </svg> 727 - </button> 728 - </div> 729 - )} 730 - {serviceEndpoint && ( 731 - <div className="user-endpoint"> 732 - <span>Service: {serviceEndpoint}</span> 733 - <button 734 - className="copy-button" 735 - onClick={(event) => { 736 - navigator.clipboard.writeText(serviceEndpoint); 737 - // Show temporary success message 738 - const button = event.currentTarget; 739 - button.classList.add('copied'); 740 - setTimeout(() => button.classList.remove('copied'), 2000); 741 - }} 742 - title="Copy Service Endpoint" 743 - > 744 - <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> 745 - <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> 746 - <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> 747 - </svg> 748 - </button> 749 - </div> 750 - )} 751 - </div> 752 - 753 - {/* Activity Chart */} 754 - <ActivityChart 755 - records={filteredChartRecords} 756 - collections={selectedCollections} 757 - loading={chartLoading} 758 - key={`chart-${Date.now()}-${filteredChartRecords.length}-${selectedCollections.join(',')}`} // Use timestamp in key to ensure re-render on refresh 759 - /> 760 - 761 - <div className="feed-controls"> 762 - <div className="filter-container"> 763 - <div 764 - className="filter-dropdown-toggle" 765 - onClick={() => setDropdownOpen(!dropdownOpen)} 766 - > 767 - <span> 768 - Filter Collections 769 - {selectedCollections.length > 0 && ( 770 - <span className="selected-collections-count"> 771 - {selectedCollections.length} 772 - </span> 773 - )} 768 + )} 769 + </div> 770 + )} 771 + 772 + {/* Initial loading state */} 773 + {initialLoad && !error && ( 774 + <div className="loading-indicator"> 775 + <div className="spinner"></div> 776 + <p>Connecting to AT Protocol services...</p> 777 + </div> 778 + )} 779 + 780 + {/* Main content once search is performed */} 781 + {searchPerformed && !initialLoad && ( 782 + <div className="user-info"> 783 + {displayName && ( 784 + <h2> 785 + Collections for {displayName} 786 + <span className="handle">@{handle}</span> 787 + </h2> 788 + )} 789 + 790 + {/* Collections count and timeframe */} 791 + {collections.length > 0 && ( 792 + <div className="collections-meta"> 793 + <p>{collections.length} collections found</p> 794 + <div className="time-toggle"> 795 + <label> 796 + <input 797 + type="checkbox" 798 + checked={useRkeyTimestamp} 799 + onChange={() => setUseRkeyTimestamp(!useRkeyTimestamp)} 800 + /> 801 + Use record IDs for timestamps 802 + </label> 803 + <span className="info-tooltip"> 804 + ? 805 + <span className="tooltip-text"> 806 + Toggle between using timestamps found within the content (more accurate) or derived from record IDs (complete coverage) 774 807 </span> 775 - <span className={`arrow ${dropdownOpen ? 'open' : ''}`}>▼</span> 776 - </div> 777 - 778 - {dropdownOpen && ( 779 - <div className="filter-dropdown-backdrop open" onClick={() => setDropdownOpen(false)} /> 780 - )} 781 - 782 - <div className={`filter-dropdown-menu ${dropdownOpen ? 'open' : ''}`}> 783 - <div className="filter-header"> 784 - <div className="filter-header-top"> 785 - <h3>Select Collections</h3> 786 - <button 787 - className="filter-close-mobile" 788 - onClick={() => setDropdownOpen(false)} 789 - aria-label="Close" 790 - > 791 - 792 - </button> 793 - </div> 794 - <div className="filter-actions"> 795 - <button 796 - className="select-all-button" 797 - onClick={(e) => { 798 - e.stopPropagation(); 799 - selectAllCollections(); 800 - }} 801 - disabled={collections.length === selectedCollections.length} 802 - > 803 - Select All 804 - </button> 805 - <button 806 - className="deselect-all-button" 807 - onClick={(e) => { 808 - e.stopPropagation(); 809 - deselectAllCollections(); 810 - }} 811 - disabled={selectedCollections.length === 0} 812 - > 813 - Deselect All 814 - </button> 815 - </div> 816 - </div> 817 - 818 - <div className="collections-filter"> 819 - {collections.map(collection => ( 820 - <div 821 - key={collection} 822 - className={`collection-item ${selectedCollections.includes(collection) ? 'selected' : ''}`} 823 - onClick={(e) => { 824 - e.stopPropagation(); 825 - toggleCollection(collection); 826 - }} 827 - > 828 - <input 829 - type="checkbox" 830 - className="collection-item-checkbox" 831 - checked={selectedCollections.includes(collection)} 832 - onChange={() => {}} // Handled by the div onClick 833 - onClick={(e) => { 834 - e.stopPropagation(); 835 - toggleCollection(collection); 836 - }} 837 - /> 838 - <span className="collection-item-name">{collection}</span> 839 - </div> 840 - ))} 841 - </div> 842 - </div> 808 + </span> 843 809 </div> 844 - 845 - <button 846 - className="refresh-button" 847 - onClick={handleRefresh} 848 - disabled={loading || selectedCollections.length === 0} 849 - title="Refresh only the feed, not the chart" 850 - > 851 - Refresh Feed 852 - </button> 853 810 </div> 854 - 855 - <div className="feed-filters"> 856 - <div className="toggle-container"> 857 - <div className="timestamp-toggle"> 858 - <label> 859 - <input 860 - type="checkbox" 861 - checked={useRkeyTimestamp} 862 - onChange={() => { 863 - // Toggle the timestamp mode 864 - const newTimestampMode = !useRkeyTimestamp; 865 - setUseRkeyTimestamp(newTimestampMode); 866 - 867 - // Refresh the feed with the new timestamp setting 868 - if (did && serviceEndpoint && selectedCollections.length > 0) { 869 - // Temporarily reset records for the loading state 870 - const currentRecords = [...records]; 871 - setRecords([]); 872 - setLoading(true); 873 - 874 - // Use our refreshed server-side approach 875 - handleRefresh() 876 - .catch(err => { 877 - console.error("Error refreshing with new timestamp mode:", err); 878 - 879 - // Restore the previous records and sort them 880 - const sorted = [...currentRecords].filter(record => { 881 - if (newTimestampMode) { // We're switching to rkey timestamps 882 - return record.rkeyTimestamp !== null; 883 - } else { // We're switching to content timestamps 884 - return record.contentTimestamp !== null; 885 - } 886 - }).sort((a, b) => { 887 - const aTime = newTimestampMode ? a.rkeyTimestamp : a.contentTimestamp; 888 - const bTime = newTimestampMode ? b.rkeyTimestamp : b.contentTimestamp; 889 - return new Date(bTime) - new Date(aTime); 890 - }); 891 - setRecords(sorted); 892 - setLoading(false); 893 - }); 894 - } else { 895 - // If we can't refetch, just resort the existing records 896 - const sorted = [...records].filter(record => { 897 - if (newTimestampMode) { // We're switching to rkey timestamps 898 - return record.rkeyTimestamp !== null; 899 - } else { // We're switching to content timestamps 900 - return record.contentTimestamp !== null; 901 - } 902 - }).sort((a, b) => { 903 - const aTime = newTimestampMode ? a.rkeyTimestamp : a.contentTimestamp; 904 - const bTime = newTimestampMode ? b.rkeyTimestamp : b.contentTimestamp; 905 - return new Date(bTime) - new Date(aTime); 906 - }); 907 - setRecords(sorted); 908 - } 909 - }} 910 - /> 911 - Use Record Key Timestamps 912 - </label> 913 - <span title="Record keys in AT Protocol encode creation timestamps which can differ from timestamps in the record content.">ⓘ</span> 914 - </div> 915 - 916 - <div className="view-toggle"> 917 - <div className="toggle-switch-container"> 918 - <label className="switch"> 811 + )} 812 + 813 + {/* Collections filter area */} 814 + {collections.length > 0 && ( 815 + <div className="collections-filter"> 816 + <h3>Collections</h3> 817 + <div className="filter-actions"> 818 + <button onClick={selectAllCollections} className="select-all">Select All</button> 819 + <button onClick={deselectAllCollections} className="deselect-all">Deselect All</button> 820 + <button onClick={handleRefresh} className="refresh-button" disabled={loading || fetchingMore}> 821 + {loading ? 'Refreshing...' : 'Refresh'} 822 + </button> 823 + </div> 824 + 825 + <div className="collections-list"> 826 + {collections.map(collection => ( 827 + <div key={collection} className="collection-item"> 828 + <label> 919 829 <input 920 830 type="checkbox" 921 - checked={compactView} 922 - onChange={() => setCompactView(!compactView)} 831 + checked={selectedCollections.includes(collection)} 832 + onChange={() => toggleCollection(collection)} 923 833 /> 924 - <span className="slider round"></span> 925 - <span className="toggle-label">{compactView ? 'Compact View' : 'Standard View'}</span> 834 + {collection} 926 835 </label> 927 836 </div> 928 - </div> 837 + ))} 929 838 </div> 930 839 </div> 931 - 932 - {selectedCollections.length === 0 ? ( 933 - <div className="no-collections-message"> 934 - <p>Please select at least one collection to view records.</p> 935 - </div> 936 - ) : ( 937 - <> 938 - <FeedTimeline 939 - records={filteredRecords} 940 - serviceEndpoint={serviceEndpoint} 941 - compactView={compactView} 942 - /> 943 - 944 - {filteredRecords.length === 0 && ( 945 - <div className="no-records-message"> 946 - <p>No records found for the selected collections.</p> 947 - </div> 948 - )} 949 - 950 - {filteredRecords.length > 0 && ( 951 - <div className="load-more-container"> 952 - <div className="records-count"> 953 - Showing {filteredRecords.length} of {filteredChartRecords.length} records 954 - </div> 955 - 956 - {canLoadMore && ( 957 - <button 958 - className="load-more-button" 959 - onClick={handleLoadMore} 960 - disabled={fetchingMore} 961 - > 962 - {fetchingMore ? 'Loading...' : 'Load More Records'} 963 - </button> 964 - )} 965 - </div> 966 - )} 967 - </> 968 - )} 969 - </div> 970 - )} 971 - </> 972 - )} 840 + )} 841 + 842 + {/* Loading indicator for chart update */} 843 + {chartLoading && ( 844 + <div className="chart-loading"> 845 + <div className="spinner"></div> 846 + <p>Loading historical data for visualization...</p> 847 + </div> 848 + )} 849 + 850 + {/* Activity Chart */} 851 + {!chartLoading && filteredChartRecords.length > 0 && ( 852 + <div className="chart-container"> 853 + <h3>Activity Timeline</h3> 854 + <ActivityChart 855 + records={filteredChartRecords} 856 + useRkeyTimestamp={useRkeyTimestamp} 857 + /> 858 + </div> 859 + )} 860 + 861 + {/* Feed heading */} 862 + {selectedCollections.length > 0 && ( 863 + <> 864 + <h3 className="feed-heading">Record Feed</h3> 865 + {filteredRecords.length === 0 && !loading && ( 866 + <p className="no-records-message">No records found for the selected collections.</p> 867 + )} 868 + </> 869 + )} 870 + 871 + {/* Feed records */} 872 + {selectedCollections.length === 0 ? ( 873 + <p className="no-collections-selected">Select at least one collection to see records.</p> 874 + ) : ( 875 + <> 876 + <FeedTimeline 877 + records={filteredRecords} 878 + useRkeyTimestamp={useRkeyTimestamp} 879 + loading={loading} 880 + /> 881 + 882 + {/* Load more button */} 883 + {filteredRecords.length > 0 && (hasMoreRecordsLocally || hasMoreRecordsRemotely) && ( 884 + <div className="load-more-container"> 885 + <button 886 + onClick={handleLoadMore} 887 + disabled={fetchingMore} 888 + className="load-more-button" 889 + > 890 + {fetchingMore ? 'Loading...' : 'Load More'} 891 + </button> 892 + </div> 893 + )} 894 + </> 895 + )} 896 + </div> 897 + )} 898 + </div> 973 899 </div> 974 900 ); 975 901 };