This repository has no description
0

Configure Feed

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

fix

+294 -213
+180 -183
src/components/CollectionsFeed/CollectionsFeed.js
··· 1 - import React, { useState, useEffect } from 'react'; 1 + import React, { useState, useEffect, useCallback } from 'react'; 2 2 import { useParams, useNavigate } from 'react-router-dom'; 3 3 import SearchBar from '../SearchBar/SearchBar'; 4 4 import FeedTimeline from './FeedTimeline'; ··· 7 7 import { resolveHandleToDid, getServiceEndpointForDid } from '../../accountData'; 8 8 import MatterLoadingAnimation from '../MatterLoadingAnimation'; 9 9 import { Helmet } from 'react-helmet'; 10 + import { useAuth } from '../../contexts/AuthContext'; 10 11 11 12 const CollectionsFeed = () => { 12 13 const { username } = useParams(); 13 14 const navigate = useNavigate(); 15 + const { isAuthenticated, checkAuthStatus } = useAuth(); 14 16 15 17 // State variables 16 18 const [handle, setHandle] = useState(username || ''); ··· 31 33 const [dropdownOpen, setDropdownOpen] = useState(false); 32 34 const [useRkeyTimestamp, setUseRkeyTimestamp] = useState(false); 33 35 const [compactView, setCompactView] = useState(false); 34 - 35 - // Effect to load data if username is provided in URL 36 - useEffect(() => { 37 - if (username) { 38 - loadUserData(username); 39 - } 40 - }, [username]); 41 - 42 - // Effect to watch for selected collections changes 43 - useEffect(() => { 44 - // Only trigger a data fetch when filters change if we haven't fetched enough data previously 45 - if (selectedCollections.length > 0 && did && serviceEndpoint && searchPerformed) { 46 - const hasUnfetchedCollections = selectedCollections.some(col => 47 - !allRecordsForChart.some(record => record.collection === col) 48 - ); 49 - 50 - if (hasUnfetchedCollections) { 51 - // Only fetch collections we haven't fetched before 52 - const collectionsToFetch = selectedCollections.filter(col => 53 - !allRecordsForChart.some(record => record.collection === col) 54 - ); 55 - 56 - if (collectionsToFetch.length > 0) { 57 - console.log(`Fetching data for new collections: ${collectionsToFetch.join(', ')}`); 58 - fetchCollectionRecords(did, serviceEndpoint, collectionsToFetch); 59 - } 60 - } else { 61 - console.log('All collections already fetched, just filtering existing data'); 62 - } 63 - } 64 - }, [selectedCollections]); 65 - 66 - // Function to load user data 67 - const loadUserData = async (userHandle) => { 68 - try { 69 - setLoading(true); 70 - setError(''); 71 - 72 - // Update URL with the username 73 - if (userHandle !== username) { 74 - navigate(`/omnifeed/${encodeURIComponent(userHandle)}`); 75 - } 76 - 77 - // Resolve handle to DID 78 - const userDid = await resolveHandleToDid(userHandle); 79 - setDid(userDid); 80 - 81 - // Get service endpoint 82 - const endpoint = await getServiceEndpointForDid(userDid); 83 - setServiceEndpoint(endpoint); 84 - 85 - // Fetch profile information 86 - const publicApiEndpoint = "https://public.api.bsky.app"; 87 - const profileResponse = await fetch(`${publicApiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(userDid)}`); 88 - 89 - if (!profileResponse.ok) { 90 - throw new Error(`Error fetching profile: ${profileResponse.statusText}`); 91 - } 92 - 93 - const profileData = await profileResponse.json(); 94 - setHandle(profileData.handle); 95 - setDisplayName(profileData.displayName || profileData.handle); 96 - 97 - // Fetch repo description to get collections 98 - const repoResponse = await fetch(`${endpoint}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(userDid)}`); 99 - 100 - if (!repoResponse.ok) { 101 - throw new Error(`Error fetching repo description: ${repoResponse.statusText}`); 102 - } 103 - 104 - const repoData = await repoResponse.json(); 105 - if (repoData.collections && repoData.collections.length > 0) { 106 - const sortedCollections = [...repoData.collections].sort(); 107 - setCollections(sortedCollections); 108 - // By default, select all collections 109 - setSelectedCollections(sortedCollections); 110 - 111 - // Fetch records for each collection 112 - await fetchCollectionRecords(userDid, endpoint, sortedCollections); 113 - } else { 114 - setError('No collections found for this user.'); 115 - } 116 - 117 - setSearchPerformed(true); 118 - setInitialLoad(false); 119 - setLoading(false); 120 - } catch (err) { 121 - console.error('Error loading user data:', err); 122 - setError(err.message || 'An error occurred while loading data.'); 123 - setInitialLoad(false); 124 - setLoading(false); 125 - } 126 - }; 36 + const [displayCount, setDisplayCount] = useState(25); 127 37 128 - // Helper function to decode TID (rkey) to timestamp 38 + // Helper functions 129 39 const tidToTimestamp = (tid) => { 130 40 try { 131 41 // TIDs use a custom base32 encoding ··· 154 64 } 155 65 }; 156 66 157 - // Helper function to extract timestamp from record 158 67 const extractTimestamp = (record) => { 159 68 // First check if createdAt exists directly in the value 160 69 if (record.value?.createdAt) { ··· 192 101 return findTimestamp(record); 193 102 }; 194 103 195 - // Function to fetch records from collections 196 - const fetchCollectionRecords = async (userDid, endpoint, collectionsList, isLoadMore = false) => { 104 + // Define fetchCollectionRecords with useCallback first 105 + const fetchCollectionRecords = useCallback(async (userDid, endpoint, collectionsList, isLoadMore = false) => { 197 106 try { 198 107 setFetchingMore(isLoadMore); 199 108 200 109 // Set chartLoading for initial load or when refreshing 201 110 if (!isLoadMore || collectionsList.length > 0) { 202 111 setChartLoading(true); 203 - console.log(`Setting chart loading to TRUE for ${isLoadMore ? 'refresh' : 'initial load'}`); 204 112 } 205 113 206 - // Array to store all fetched records 114 + // Arrays to store all fetched records 207 115 let allRecords = isLoadMore ? [...records] : []; 208 116 let allChartRecords = isLoadMore ? [...allRecordsForChart] : []; 209 117 const newCursors = { ...collectionCursors }; ··· 222 130 let pageCount = 0; 223 131 let collectionRecords = []; 224 132 let reachedCutoff = false; 225 - let totalRecordsForCollection = 0; 226 133 227 134 // For initial deep load, we need to paginate as many times as needed to get all historical data 228 135 // For regular timeline browsing or load more, we just get one page ··· 230 137 const maxPages = isInitialDeepLoad ? 1000 : 1; 231 138 232 139 while (hasMoreRecords && pageCount < maxPages && !reachedCutoff) { 233 - // Fetch up to 100 records per page 234 - let url = `${endpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(userDid)}&collection=${encodeURIComponent(collection)}&limit=100`; 140 + // Use our server-side API to fetch records 141 + let url = `/api/collections/${encodeURIComponent(userDid)}/records?endpoint=${encodeURIComponent(endpoint)}&collection=${encodeURIComponent(collection)}&limit=100`; 235 142 236 143 // Add cursor if we have one 237 144 if (cursor) { 238 145 url += `&cursor=${encodeURIComponent(cursor)}`; 239 146 } 240 147 241 - console.log(`Fetching ${collection} page ${pageCount + 1}${cursor ? ' with cursor' : ''}`); 242 - 243 - const response = await fetch(url); 148 + const response = await fetch(url, { 149 + credentials: 'include' 150 + }); 244 151 245 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 + } 246 158 console.error(`Error fetching records for ${collection}: ${response.statusText}`); 247 159 break; 248 160 } ··· 252 164 253 165 // Process records from this page 254 166 if (data.records && data.records.length > 0) { 255 - console.log(`Received ${data.records.length} records for ${collection} page ${pageCount}`); 256 - totalRecordsForCollection += data.records.length; 257 - 258 167 const processedRecords = data.records.map(record => { 259 168 const contentTimestamp = extractTimestamp(record); 260 169 const rkey = record.uri.split('/').pop(); ··· 307 216 // All records on this page are within our date range 308 217 // OR we need to keep paginating through high-volume collections 309 218 collectionRecords.push(...processedRecords); 310 - 311 - // If we found some records close to the cutoff date but haven't reached it yet, 312 - // log this for debugging purposes 313 - if (oldestRecordTime < cutoffDate.getTime() + (7 * 24 * 60 * 60 * 1000)) { // within 7 days of cutoff 314 - console.log(` - Getting close to cutoff date, oldest record = ${new Date(oldestRecordTime).toISOString()}`); 315 - } 316 219 } 317 220 } else { 318 221 // For regular browsing, include all records from the page ··· 341 244 delete newCursors[collection]; 342 245 } 343 246 344 - console.log(`Finished fetching ${collection}: Retrieved ${totalRecordsForCollection} records in ${pageCount} pages`); 345 - console.log(` - After filtering: ${collectionRecords.length} records in 90-day window`); 346 - if (reachedCutoff) { 347 - console.log(` - Stopped because records older than 90 days were found`); 348 - } else if (!cursor) { 349 - console.log(` - Stopped because no more records were available`); 350 - } else if (pageCount >= maxPages) { 351 - console.log(` - Stopped because max page limit (${maxPages}) was reached`); 352 - } 353 - 354 247 // Add records to appropriate arrays 355 248 allChartRecords = [...allChartRecords, ...collectionRecords]; 356 249 357 250 // For display timeline, we might want to be more selective 358 251 if (isLoadMore || !isInitialDeepLoad) { 359 252 allRecords = [...allRecords, ...collectionRecords]; 360 - } else if (isInitialDeepLoad) { 361 - // For initial load, we'll filter later to just show the most recent 362 - // This keeps allRecords separate from chart data until we're done 363 253 } 364 254 } 365 255 ··· 391 281 displayRecords = filterAndSort(allRecords); 392 282 } 393 283 394 - // Create a summary of records per collection for debugging 395 - const collectionSummary = {}; 396 - sortedChartRecords.forEach(record => { 397 - if (!collectionSummary[record.collection]) { 398 - collectionSummary[record.collection] = 0; 399 - } 400 - collectionSummary[record.collection]++; 401 - }); 402 - 403 - console.log("Collection record counts in 90-day period:"); 404 - Object.entries(collectionSummary) 405 - .sort((a, b) => b[1] - a[1]) // Sort by count descending 406 - .forEach(([collection, count]) => { 407 - console.log(` - ${collection}: ${count} records`); 408 - }); 409 - 410 - console.log(`Fetched ${sortedChartRecords.length} total records for chart, showing ${displayRecords.length} in timeline`); 411 - 412 284 // In the case of a refresh, sortedChartRecords only contains fresh data for selected collections 413 285 // We need to merge this with any existing data for other collections 414 286 const existingRecordsToKeep = isLoadMore ? [] : allRecordsForChart.filter(record => ··· 420 292 421 293 // For refresh, remove old data for the collections we just refreshed 422 294 if (!isLoadMore) { 423 - console.log(`Removing old data for refreshed collections: ${collectionsList.join(', ')}`); 424 - 425 - // Count for stats 426 - const beforeCount = existingRecordsToKeep.length + sortedChartRecords.length; 427 - 428 295 // Remove duplicates that might exist in both arrays 429 296 // This can happen if we refreshed a collection we already had data for 430 297 const uniqueNewRecords = sortedChartRecords.filter(newRecord => { ··· 436 303 437 304 // Merge fresh data with existing data for other collections 438 305 mergedRecords = [...existingRecordsToKeep, ...uniqueNewRecords]; 439 - console.log(`Merged ${existingRecordsToKeep.length} existing records with ${uniqueNewRecords.length} new unique records`); 440 - console.log(`Total records: ${beforeCount} before deduplication, ${mergedRecords.length} after`); 441 306 } 442 307 else { 443 308 // For load more, just add all the new records 444 309 mergedRecords = [...existingRecordsToKeep, ...sortedChartRecords]; 445 310 } 446 311 447 - console.log(`Final chart dataset size: ${mergedRecords.length} records`); 448 - 449 312 // Update state 450 313 setRecords(displayRecords); 451 314 setAllRecordsForChart(mergedRecords); ··· 454 317 455 318 // Always set chartLoading to false when done, regardless of initial state 456 319 setChartLoading(false); 457 - console.log("Setting chart loading to FALSE"); 458 320 459 321 } catch (err) { 460 322 console.error('Error fetching collection records:', err); 461 323 setError('Failed to fetch records. Please try again.'); 462 324 setFetchingMore(false); 463 325 setChartLoading(false); // Always reset on error 464 - console.log("Setting chart loading to FALSE (error case)"); 326 + throw err; // Re-throw to allow handling in calling functions 327 + } 328 + }, [records, allRecordsForChart, collectionCursors, useRkeyTimestamp, checkAuthStatus]); 329 + 330 + // Now define loadUserData after fetchCollectionRecords is defined 331 + const loadUserData = useCallback(async (userHandle) => { 332 + try { 333 + setLoading(true); 334 + setError(''); 335 + 336 + // Update URL with the username 337 + if (userHandle !== username) { 338 + navigate(`/omnifeed/${encodeURIComponent(userHandle)}`); 339 + } 340 + 341 + // Resolve handle to DID 342 + const userDid = await resolveHandleToDid(userHandle); 343 + setDid(userDid); 344 + 345 + // Get service endpoint 346 + const endpoint = await getServiceEndpointForDid(userDid); 347 + setServiceEndpoint(endpoint); 348 + 349 + // 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}`); 355 + } 356 + 357 + const profileData = await profileResponse.json(); 358 + setHandle(profileData.handle); 359 + setDisplayName(profileData.displayName || profileData.handle); 360 + 361 + // Use our server-side API to fetch collections 362 + const collectionsResponse = await fetch(`/api/collections/${encodeURIComponent(userDid)}?endpoint=${encodeURIComponent(endpoint)}`, { 363 + credentials: 'include' 364 + }); 365 + 366 + if (!collectionsResponse.ok) { 367 + if (collectionsResponse.status === 401) { 368 + // Handle unauthorized 369 + checkAuthStatus(); 370 + throw new Error('Authentication required. Please log in again.'); 371 + } 372 + throw new Error(`Error fetching collections: ${collectionsResponse.statusText}`); 373 + } 374 + 375 + const collectionsData = await collectionsResponse.json(); 376 + 377 + if (collectionsData.collections && collectionsData.collections.length > 0) { 378 + const sortedCollections = [...collectionsData.collections].sort(); 379 + setCollections(sortedCollections); 380 + // By default, select all collections 381 + setSelectedCollections(sortedCollections); 382 + 383 + // Fetch records for each collection 384 + await fetchCollectionRecords(userDid, endpoint, sortedCollections); 385 + } else { 386 + setError('No collections found for this user.'); 387 + } 388 + 389 + setSearchPerformed(true); 390 + setInitialLoad(false); 391 + setLoading(false); 392 + } catch (err) { 393 + console.error('Error loading user data:', err); 394 + setError(err.message || 'An error occurred while loading data.'); 395 + setInitialLoad(false); 396 + setLoading(false); 397 + } 398 + }, [username, navigate, checkAuthStatus, fetchCollectionRecords]); 399 + 400 + // Now place useEffects after all the callbacks are defined 401 + 402 + // Verify authentication first 403 + useEffect(() => { 404 + const verifyAuth = async () => { 405 + try { 406 + await checkAuthStatus(); 407 + if (!isAuthenticated) { 408 + // Save the current path for redirect after login 409 + const returnUrl = encodeURIComponent(window.location.pathname); 410 + navigate(`/login?returnUrl=${returnUrl}`); 411 + } 412 + } catch (err) { 413 + console.error('Auth verification failed:', err); 414 + navigate('/login'); 415 + } 416 + }; 417 + 418 + verifyAuth(); 419 + 420 + // Set up periodic auth checks 421 + const interval = setInterval(checkAuthStatus, 30000); // Check every 30 seconds 422 + 423 + return () => clearInterval(interval); 424 + }, [isAuthenticated, checkAuthStatus, navigate]); 425 + 426 + // Effect to load data if username is provided in URL 427 + useEffect(() => { 428 + // Only load data if authenticated and username is available 429 + if (username && isAuthenticated) { 430 + loadUserData(username); 431 + } 432 + }, [username, isAuthenticated, loadUserData]); 433 + 434 + // Effect to watch for selected collections changes 435 + useEffect(() => { 436 + // Only trigger a data fetch when filters change if we haven't fetched enough data previously 437 + if (selectedCollections.length > 0 && did && serviceEndpoint && searchPerformed) { 438 + const hasUnfetchedCollections = selectedCollections.some(col => 439 + !allRecordsForChart.some(record => record.collection === col) 440 + ); 441 + 442 + if (hasUnfetchedCollections) { 443 + // Only fetch collections we haven't fetched before 444 + const collectionsToFetch = selectedCollections.filter(col => 445 + !allRecordsForChart.some(record => record.collection === col) 446 + ); 447 + 448 + if (collectionsToFetch.length > 0) { 449 + console.log(`Fetching data for new collections: ${collectionsToFetch.join(', ')}`); 450 + fetchCollectionRecords(did, serviceEndpoint, collectionsToFetch); 451 + } 452 + } else { 453 + console.log('All collections already fetched, just filtering existing data'); 454 + } 465 455 } 466 - }; 456 + }, [selectedCollections, did, serviceEndpoint, searchPerformed, allRecordsForChart, fetchCollectionRecords]); 467 457 468 458 // Toggle collection selection 469 459 const toggleCollection = (collection) => { ··· 498 488 // Handle refresh button click - only updates the feed, not the chart 499 489 const handleRefresh = async () => { 500 490 if (did && serviceEndpoint) { 501 - console.log("Refresh requested for selected collections:", selectedCollections); 502 - 503 491 // Set a loading state but not for the chart 504 492 setLoading(true); 505 493 ··· 514 502 // Process each selected collection sequentially 515 503 for (const collection of selectedCollections) { 516 504 try { 517 - // Fetch just one page of the most recent records 518 - const url = `${serviceEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&limit=25`; 505 + // Use server-side API endpoint for fetching records 506 + const url = `/api/collections/${encodeURIComponent(did)}/records?endpoint=${encodeURIComponent(serviceEndpoint)}&collection=${encodeURIComponent(collection)}&limit=25`; 519 507 520 - const response = await fetch(url); 508 + const response = await fetch(url, { 509 + credentials: 'include' 510 + }); 521 511 522 512 if (!response.ok) { 513 + if (response.status === 401) { 514 + // Handle unauthorized 515 + checkAuthStatus(); 516 + throw new Error('Authentication required. Please log in again.'); 517 + } 523 518 console.error(`Error refreshing ${collection}: ${response.statusText}`); 524 519 continue; // Skip this collection but continue with others 525 520 } ··· 527 522 const data = await response.json(); 528 523 529 524 if (data.records && data.records.length > 0) { 530 - console.log(`Refreshed ${data.records.length} records for ${collection}`); 531 - 532 525 // Process the records with timestamps 533 526 const processedRecords = data.records.map(record => { 534 527 const contentTimestamp = extractTimestamp(record); ··· 569 562 570 563 // Only update the feed display records, not the chart data 571 564 setRecords(sortedRecords.slice(0, 25)); 572 - console.log(`Feed refreshed with ${sortedRecords.length} records`); 573 565 }; 574 566 575 567 await refreshOnlyFeed(); 576 - console.log("Feed refresh completed successfully"); 577 568 } catch (err) { 578 569 console.error("Error during feed refresh:", err); 579 570 setError('Failed to refresh records. Please try again.'); ··· 586 577 587 578 // Handle load more button click 588 579 const handleLoadMore = async () => { 580 + // Verify authentication before proceeding 581 + if (!isAuthenticated) { 582 + checkAuthStatus(); 583 + return; 584 + } 585 + 589 586 setFetchingMore(true); 590 587 591 588 // First check if we already have more records locally that we can show 592 589 if (hasMoreRecordsLocally) { 593 - console.log("Loading more records from local cache"); 594 590 // Simply increase the display count by 25 more records 595 591 const nextBatchSize = 25; 596 592 setDisplayCount(prevCount => prevCount + nextBatchSize); 597 - console.log(`Increasing display count to ${displayCount + nextBatchSize}`); 598 593 599 594 setFetchingMore(false); 600 595 } 601 596 // If we've displayed all local records but have cursors to fetch more from the API 602 597 else if (hasMoreRecordsRemotely) { 603 - console.log("Fetching more records from API"); 604 598 // Only load more from collections that have cursors and are selected 605 599 const collectionsToLoad = selectedCollections.filter(collection => collectionCursors[collection]); 606 600 607 601 if (collectionsToLoad.length > 0) { 608 - await fetchCollectionRecords(did, serviceEndpoint, collectionsToLoad, true); 609 - // After fetching more, we can increase the display count to show them 610 - setDisplayCount(prevCount => prevCount + 25); 602 + try { 603 + await fetchCollectionRecords(did, serviceEndpoint, collectionsToLoad, true); 604 + // After fetching more, we can increase the display count to show them 605 + setDisplayCount(prevCount => prevCount + 25); 606 + } catch (error) { 607 + if (error.message?.includes('Authentication required')) { 608 + // Handle authentication errors 609 + const returnUrl = encodeURIComponent(window.location.pathname); 610 + navigate(`/login?returnUrl=${returnUrl}`); 611 + } else { 612 + setError('Failed to load more records. Please try again.'); 613 + } 614 + } 611 615 } else { 612 616 setFetchingMore(false); 613 617 } 614 618 } else { 615 - console.log("No more records to load"); 616 619 setFetchingMore(false); 617 620 } 618 621 }; ··· 622 625 selectedCollections.includes(record.collection) 623 626 ); 624 627 625 - // State to track how many records to display 626 - const [displayCount, setDisplayCount] = useState(25); 627 - 628 628 // For timeline display, directly use the chart records but limit to the current displayCount 629 629 // This ensures we always show the most recent records for the selected collections 630 630 const filteredRecords = filteredChartRecords ··· 866 866 867 867 // Refresh the feed with the new timestamp setting 868 868 if (did && serviceEndpoint && selectedCollections.length > 0) { 869 - // We need to refetch to ensure we get all records 870 - // Store the current mode for fetchCollectionRecords 871 - const currentMode = useRkeyTimestamp; 872 - 873 869 // Temporarily reset records for the loading state 874 870 const currentRecords = [...records]; 875 871 setRecords([]); 876 872 setLoading(true); 877 873 878 - // Fetch new records with the current selection 879 - fetchCollectionRecords(did, serviceEndpoint, selectedCollections) 874 + // Use our refreshed server-side approach 875 + handleRefresh() 880 876 .catch(err => { 881 877 console.error("Error refreshing with new timestamp mode:", err); 878 + 882 879 // Restore the previous records and sort them 883 880 const sorted = [...currentRecords].filter(record => { 884 881 if (newTimestampMode) { // We're switching to rkey timestamps
+25 -6
src/components/Login/Login.js
··· 1 - import React, { useState } from 'react'; 2 - import { useNavigate } from 'react-router-dom'; 1 + import React, { useState, useEffect } from 'react'; 2 + import { useNavigate, useLocation } from 'react-router-dom'; 3 3 import { useAuth } from '../../contexts/AuthContext'; 4 4 import './Login.css'; 5 5 ··· 7 7 const [handle, setHandle] = useState(''); 8 8 const [isLoading, setIsLoading] = useState(false); 9 9 const [error, setError] = useState(''); 10 + const [returnUrl, setReturnUrl] = useState(''); 10 11 const { login, isAuthenticated } = useAuth(); 11 12 const navigate = useNavigate(); 13 + const location = useLocation(); 14 + 15 + // Extract returnUrl from query params 16 + useEffect(() => { 17 + const searchParams = new URLSearchParams(location.search); 18 + const returnPath = searchParams.get('returnUrl'); 19 + if (returnPath) { 20 + setReturnUrl(returnPath); 21 + } 22 + }, [location]); 12 23 13 24 // Redirect if already authenticated 14 - React.useEffect(() => { 25 + useEffect(() => { 15 26 if (isAuthenticated) { 16 - navigate('/'); 27 + // Navigate to return URL if it exists, otherwise to home 28 + navigate(returnUrl || '/'); 17 29 } 18 - }, [isAuthenticated, navigate]); 30 + }, [isAuthenticated, navigate, returnUrl]); 19 31 20 32 const handleSubmit = async (e) => { 21 33 e.preventDefault(); ··· 29 41 setError(''); 30 42 31 43 try { 32 - await login(handle); 44 + // Pass returnUrl to login function 45 + await login(handle, returnUrl); 33 46 // Note: This code won't run because login redirects to Bluesky OAuth page 34 47 } catch (err) { 35 48 setError('Authentication failed. Please try again.'); ··· 42 55 <div className="login-card"> 43 56 <h2>Login with Bluesky</h2> 44 57 <p>Sign in with your Bluesky handle to access protected features.</p> 58 + 59 + {returnUrl && ( 60 + <div className="return-notice"> 61 + <p>You'll be redirected back to the page you were trying to access after logging in.</p> 62 + </div> 63 + )} 45 64 46 65 {error && <div className="login-error">{error}</div>} 47 66
+49 -17
src/components/Login/LoginCallback.js
··· 1 1 import React, { useEffect, useState } from 'react'; 2 - import { Navigate } from 'react-router-dom'; 2 + import { Navigate, useLocation } from 'react-router-dom'; 3 3 import { useAuth } from '../../contexts/AuthContext'; 4 4 import Loading from '../Loading/Loading'; 5 5 6 6 // This component handles the callback redirect from the Bluesky OAuth process 7 7 const LoginCallback = () => { 8 - const { loading } = useAuth(); 8 + const { loading, checkAuthStatus } = useAuth(); 9 9 const [error, setError] = useState(null); 10 + const [returnUrl, setReturnUrl] = useState('/'); 11 + const location = useLocation(); 10 12 11 13 useEffect(() => { 12 - // The actual callback handling is done in the AuthContext.js 13 - // through the client.init() method that automatically processes 14 - // the URL params when the page loads 15 - 16 - // We just check if there are any errors in the URL 17 - const urlParams = new URLSearchParams(window.location.search); 18 - const errorParam = urlParams.get('error'); 19 - const errorDescription = urlParams.get('error_description'); 20 - 21 - if (errorParam) { 22 - setError(errorDescription || errorParam); 23 - } 24 - }, []); 14 + const handleCallback = async () => { 15 + try { 16 + // Get return URL from session storage or state parameter 17 + const sessionReturnUrl = sessionStorage.getItem('returnUrl'); 18 + const params = new URLSearchParams(location.search); 19 + const stateParam = params.get('state'); 20 + 21 + // If state contains encoded returnUrl, extract it 22 + let decodedState = null; 23 + if (stateParam) { 24 + try { 25 + decodedState = JSON.parse(atob(stateParam)); 26 + if (decodedState && decodedState.returnUrl) { 27 + setReturnUrl(decodedState.returnUrl); 28 + } 29 + } catch (e) { 30 + console.error('Failed to decode state parameter:', e); 31 + } 32 + } 33 + 34 + // Prioritize returnUrl from session storage if available 35 + if (sessionReturnUrl) { 36 + setReturnUrl(sessionReturnUrl); 37 + sessionStorage.removeItem('returnUrl'); 38 + } 39 + 40 + // Check for error in URL parameters 41 + const errorParam = params.get('error'); 42 + if (errorParam) { 43 + setError(errorParam); 44 + return; 45 + } 46 + 47 + // Check server-side authentication status 48 + await checkAuthStatus(); 49 + } catch (err) { 50 + console.error('Error handling login callback:', err); 51 + setError('Failed to complete login process'); 52 + } 53 + }; 54 + 55 + handleCallback(); 56 + }, [location, checkAuthStatus]); 25 57 26 58 if (loading) { 27 59 return <Loading message="Processing login..." />; ··· 39 71 ); 40 72 } 41 73 42 - // Redirect to the home page if no errors 43 - return <Navigate to="/" replace />; 74 + // Redirect to the return URL (or home by default) 75 + return <Navigate to={returnUrl} replace />; 44 76 }; 45 77 46 78 export default LoginCallback;
+10 -2
src/components/ProtectedRoute.js
··· 1 - import React from 'react'; 1 + import React, { useEffect } from 'react'; 2 2 import { Navigate } from 'react-router-dom'; 3 3 import { useAuth } from '../contexts/AuthContext'; 4 4 import { isAccountAllowed } from '../config/allowlist'; ··· 6 6 7 7 // Component to protect routes that require authentication 8 8 const ProtectedRoute = ({ children }) => { 9 - const { isAuthenticated, loading, session } = useAuth(); 9 + const { isAuthenticated, loading, session, checkAuthStatus } = useAuth(); 10 + 11 + useEffect(() => { 12 + // Check auth status on mount and periodically 13 + checkAuthStatus(); 14 + const interval = setInterval(checkAuthStatus, 30000); // Check every 30 seconds 15 + 16 + return () => clearInterval(interval); 17 + }, [checkAuthStatus]); 10 18 11 19 // Show loading state while authentication is being checked 12 20 if (loading) {
+13 -4
src/contexts/AuthContext.js
··· 69 69 }, []); 70 70 71 71 // Initiate the login process 72 - const login = async (handle) => { 72 + const login = async (handle, returnUrl) => { 73 73 if (!client) return; 74 74 75 75 try { 76 - // The signIn method will redirect the user to the OAuth server 77 - await client.signIn(handle); 78 - // This code won't execute as the page will be redirected 76 + // Save returnUrl to session storage if provided 77 + if (returnUrl) { 78 + sessionStorage.setItem('returnUrl', returnUrl); 79 + } 80 + 81 + // Create state parameter with returnUrl 82 + const state = returnUrl ? 83 + btoa(JSON.stringify({ returnUrl })) : 84 + undefined; 85 + 86 + // Pass state parameter to signIn method 87 + await client.signIn(handle, { state }); 79 88 } catch (err) { 80 89 console.error('Login failed:', err); 81 90 setError(err.message);
+17 -1
vercel.json
··· 1 1 { 2 2 "rewrites": [ 3 - { "source": "/(.*)", "destination": "/index.html" } 3 + { 4 + "source": "/api/(.*)", 5 + "destination": "https://api.cred.blue/api/$1" 6 + }, 7 + { 8 + "source": "/(.*)", 9 + "destination": "/index.html" 10 + } 4 11 ], 5 12 "headers": [ 6 13 { ··· 9 16 { "key": "Access-Control-Allow-Origin", "value": "*" }, 10 17 { "key": "Content-Type", "value": "application/json" }, 11 18 { "key": "Cache-Control", "value": "public, max-age=300" } 19 + ] 20 + }, 21 + { 22 + "source": "/api/(.*)", 23 + "headers": [ 24 + { "key": "Access-Control-Allow-Credentials", "value": "true" }, 25 + { "key": "Access-Control-Allow-Origin", "value": "*" }, 26 + { "key": "Access-Control-Allow-Methods", "value": "GET,OPTIONS,PATCH,DELETE,POST,PUT" }, 27 + { "key": "Access-Control-Allow-Headers", "value": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version" } 12 28 ] 13 29 } 14 30 ]