A social knowledge tool for researchers built on ATProto

feat: add explore page with global feed and feed item component

Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) <aider@aider.chat>

+274
+130
src/webapp/app/(authenticated)/explore/page.tsx
··· 1 + 'use client'; 2 + 3 + import { useEffect, useState, useMemo, useCallback } from 'react'; 4 + import { ApiClient } from '@/api-client/ApiClient'; 5 + import { getAccessToken } from '@/services/auth'; 6 + import { FeedItem } from '@/components/FeedItem'; 7 + import type { GetGlobalFeedResponse } from '@/api-client/types'; 8 + import { 9 + Button, 10 + Group, 11 + Loader, 12 + Stack, 13 + Title, 14 + Text, 15 + Center, 16 + } from '@mantine/core'; 17 + 18 + export default function ExplorePage() { 19 + const [feedItems, setFeedItems] = useState<GetGlobalFeedResponse['activities']>([]); 20 + const [loading, setLoading] = useState(true); 21 + const [loadingMore, setLoadingMore] = useState(false); 22 + const [hasMore, setHasMore] = useState(true); 23 + const [error, setError] = useState<string | null>(null); 24 + 25 + // Memoize API client instance 26 + const apiClient = useMemo( 27 + () => 28 + new ApiClient( 29 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 30 + () => getAccessToken(), 31 + ), 32 + [], 33 + ); 34 + 35 + // Fetch initial feed data 36 + const fetchFeed = useCallback(async (reset = false) => { 37 + try { 38 + if (reset) { 39 + setLoading(true); 40 + setError(null); 41 + } else { 42 + setLoadingMore(true); 43 + } 44 + 45 + const response = await apiClient.getGlobalFeed({ 46 + limit: 20, 47 + beforeActivityId: reset ? undefined : feedItems[feedItems.length - 1]?.id, 48 + }); 49 + 50 + if (reset) { 51 + setFeedItems(response.activities); 52 + } else { 53 + setFeedItems(prev => [...prev, ...response.activities]); 54 + } 55 + 56 + setHasMore(response.pagination.hasMore); 57 + } catch (error: any) { 58 + console.error('Error fetching feed:', error); 59 + setError(error.message || 'Failed to load feed'); 60 + } finally { 61 + setLoading(false); 62 + setLoadingMore(false); 63 + } 64 + }, [apiClient, feedItems]); 65 + 66 + useEffect(() => { 67 + fetchFeed(true); 68 + }, [apiClient]); // Only depend on apiClient, not fetchFeed to avoid infinite loop 69 + 70 + const handleLoadMore = () => { 71 + if (!loadingMore && hasMore) { 72 + fetchFeed(false); 73 + } 74 + }; 75 + 76 + const handleRetry = () => { 77 + fetchFeed(true); 78 + }; 79 + 80 + if (loading) { 81 + return ( 82 + <Center h={200}> 83 + <Loader /> 84 + </Center> 85 + ); 86 + } 87 + 88 + if (error) { 89 + return ( 90 + <Center h={200}> 91 + <Stack align="center"> 92 + <Text c="red">{error}</Text> 93 + <Button onClick={handleRetry} variant="outline"> 94 + Try Again 95 + </Button> 96 + </Stack> 97 + </Center> 98 + ); 99 + } 100 + 101 + return ( 102 + <Stack> 103 + <Title order={2}>Explore</Title> 104 + 105 + {feedItems.length === 0 ? ( 106 + <Center h={200}> 107 + <Text c="dimmed">No activity to show yet</Text> 108 + </Center> 109 + ) : ( 110 + <Stack gap="lg"> 111 + {feedItems.map((item) => ( 112 + <FeedItem key={item.id} item={item} /> 113 + ))} 114 + 115 + {hasMore && ( 116 + <Center> 117 + <Button 118 + onClick={handleLoadMore} 119 + loading={loadingMore} 120 + variant="outline" 121 + > 122 + {loadingMore ? 'Loading...' : 'Load More'} 123 + </Button> 124 + </Center> 125 + )} 126 + </Stack> 127 + )} 128 + </Stack> 129 + ); 130 + }
+31
src/webapp/app/(authenticated)/profile/[handle]/page.tsx
··· 1 + 'use client'; 2 + 3 + import { useParams } from 'next/navigation'; 4 + import { 5 + Stack, 6 + Title, 7 + Text, 8 + Card, 9 + Center, 10 + } from '@mantine/core'; 11 + 12 + export default function ProfilePage() { 13 + const params = useParams(); 14 + const handle = params.handle as string; 15 + 16 + return ( 17 + <Center h={400}> 18 + <Card withBorder p="xl"> 19 + <Stack align="center"> 20 + <Title order={2}>Profile Page</Title> 21 + <Text c="dimmed"> 22 + Profile for @{handle} - Coming Soon! 23 + </Text> 24 + <Text size="sm" c="dimmed"> 25 + This page will show user profile information and their public activity. 26 + </Text> 27 + </Stack> 28 + </Card> 29 + </Center> 30 + ); 31 + }
+113
src/webapp/components/FeedItem.tsx
··· 1 + 'use client'; 2 + 3 + import { useRouter } from 'next/navigation'; 4 + import { UrlCard } from './UrlCard'; 5 + import type { FeedItem as FeedItemType } from '@/api-client/types'; 6 + import { 7 + Stack, 8 + Text, 9 + Group, 10 + Anchor, 11 + Box, 12 + } from '@mantine/core'; 13 + 14 + interface FeedItemProps { 15 + item: FeedItemType; 16 + } 17 + 18 + export function FeedItem({ item }: FeedItemProps) { 19 + const router = useRouter(); 20 + 21 + const handleUserClick = (handle: string) => { 22 + router.push(`/profile/${handle}`); 23 + }; 24 + 25 + const handleCollectionClick = (collectionId: string) => { 26 + router.push(`/collections/${collectionId}`); 27 + }; 28 + 29 + const renderActivityText = () => { 30 + const { user, collections } = item; 31 + 32 + if (collections.length === 0) { 33 + return ( 34 + <Text size="sm" c="dimmed"> 35 + <Anchor 36 + component="button" 37 + onClick={() => handleUserClick(user.handle)} 38 + c="blue" 39 + > 40 + @{user.handle} 41 + </Anchor> 42 + {' '}added to library 43 + </Text> 44 + ); 45 + } 46 + 47 + if (collections.length === 1) { 48 + return ( 49 + <Text size="sm" c="dimmed"> 50 + <Anchor 51 + component="button" 52 + onClick={() => handleUserClick(user.handle)} 53 + c="blue" 54 + > 55 + @{user.handle} 56 + </Anchor> 57 + {' '}added to{' '} 58 + <Anchor 59 + component="button" 60 + onClick={() => handleCollectionClick(collections[0].id)} 61 + c="blue" 62 + > 63 + {collections[0].name} 64 + </Anchor> 65 + </Text> 66 + ); 67 + } 68 + 69 + return ( 70 + <Text size="sm" c="dimmed"> 71 + <Anchor 72 + component="button" 73 + onClick={() => handleUserClick(user.handle)} 74 + c="blue" 75 + > 76 + @{user.handle} 77 + </Anchor> 78 + {' '}added to{' '} 79 + {collections.map((collection, index) => ( 80 + <span key={collection.id}> 81 + <Anchor 82 + component="button" 83 + onClick={() => handleCollectionClick(collection.id)} 84 + c="blue" 85 + > 86 + {collection.name} 87 + </Anchor> 88 + {index < collections.length - 2 && ', '} 89 + {index === collections.length - 2 && ' and '} 90 + </span> 91 + ))} 92 + </Text> 93 + ); 94 + }; 95 + 96 + return ( 97 + <Stack gap="xs"> 98 + {renderActivityText()} 99 + <Box pl="md"> 100 + <UrlCard 101 + cardId={item.card.id} 102 + url={item.card.url} 103 + title={item.card.cardContent.title} 104 + description={item.card.cardContent.description} 105 + author={item.card.cardContent.author} 106 + imageUrl={item.card.cardContent.thumbnailUrl} 107 + addedAt={item.card.createdAt} 108 + note={item.card.note?.text} 109 + /> 110 + </Box> 111 + </Stack> 112 + ); 113 + }