This repository has no description
0

Configure Feed

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

feat: pagination

+470 -320
+6 -6
src/webapp/app/(authenticated)/cards/[cardId]/page.tsx
··· 29 29 const params = useParams(); 30 30 const cardId = params.cardId as string; 31 31 32 - // Create API client instance 33 - const apiClient = new ApiClient( 34 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 35 - () => getAccessToken(), 36 - ); 37 - 38 32 useEffect(() => { 33 + // Create API client instance 34 + const apiClient = new ApiClient( 35 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 36 + () => getAccessToken(), 37 + ); 38 + 39 39 const fetchCard = async () => { 40 40 try { 41 41 setLoading(true);
+7
src/webapp/app/(authenticated)/explore/error.tsx
··· 1 + 'use client'; 2 + 3 + import MyFeedContainerError from '@/features/feeds/containers/myFeedContainer/Error.MyFeedContainer'; 4 + 5 + export default function Error() { 6 + return <MyFeedContainerError />; 7 + }
+5
src/webapp/app/(authenticated)/explore/loading.tsx
··· 1 + import MyFeedContainerSkeleton from '@/features/feeds/containers/myFeedContainer/Skeleton.MyFeedContainer'; 2 + 3 + export default function Loading() { 4 + return <MyFeedContainerSkeleton />; 5 + }
+3 -133
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 '@/features/feeds/components/feedItem/FeedItem'; 7 - import type { GetGlobalFeedResponse } from '@/api-client/types'; 8 - import { 9 - Button, 10 - Loader, 11 - Stack, 12 - Title, 13 - Text, 14 - Center, 15 - Container, 16 - } from '@mantine/core'; 17 - 18 - export default function ExplorePage() { 19 - const [feedItems, setFeedItems] = useState< 20 - GetGlobalFeedResponse['activities'] 21 - >([]); 22 - const [loading, setLoading] = useState(true); 23 - const [loadingMore, setLoadingMore] = useState(false); 24 - const [hasMore, setHasMore] = useState(true); 25 - const [error, setError] = useState<string | null>(null); 26 - 27 - // Memoize API client instance 28 - const apiClient = useMemo( 29 - () => 30 - new ApiClient( 31 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 32 - () => getAccessToken(), 33 - ), 34 - [], 35 - ); 36 - 37 - // Fetch initial feed data 38 - const fetchFeed = useCallback( 39 - async (reset = false) => { 40 - try { 41 - if (reset) { 42 - setLoading(true); 43 - setError(null); 44 - } else { 45 - setLoadingMore(true); 46 - } 47 - 48 - const response = await apiClient.getGlobalFeed({ 49 - limit: 20, 50 - beforeActivityId: reset 51 - ? undefined 52 - : feedItems[feedItems.length - 1]?.id, 53 - }); 54 - 55 - if (reset) { 56 - setFeedItems(response.activities); 57 - } else { 58 - setFeedItems((prev) => [...prev, ...response.activities]); 59 - } 60 - 61 - setHasMore(response.pagination.hasMore); 62 - } catch (error: any) { 63 - console.error('Error fetching feed:', error); 64 - setError(error.message || 'Failed to load feed'); 65 - } finally { 66 - setLoading(false); 67 - setLoadingMore(false); 68 - } 69 - }, 70 - [apiClient, feedItems], 71 - ); 72 - 73 - useEffect(() => { 74 - fetchFeed(true); 75 - }, [apiClient]); // Only depend on apiClient, not fetchFeed to avoid infinite loop 76 - 77 - const handleLoadMore = () => { 78 - if (!loadingMore && hasMore) { 79 - fetchFeed(false); 80 - } 81 - }; 82 - 83 - const handleRetry = () => { 84 - fetchFeed(true); 85 - }; 86 - 87 - if (loading) { 88 - return <Loader />; 89 - } 90 - 91 - if (error) { 92 - return ( 93 - <Center h={200}> 94 - <Stack align="center"> 95 - <Text c="red">{error}</Text> 96 - <Button onClick={handleRetry} variant="outline"> 97 - Try Again 98 - </Button> 99 - </Stack> 100 - </Center> 101 - ); 102 - } 1 + import MyFeedContainer from '@/features/feeds/containers/myFeedContainer/MyFeedContainer'; 103 2 104 - return ( 105 - <Container p={'xs'} size={'xl'}> 106 - <Stack> 107 - <Title order={2}>Explore</Title> 108 - 109 - {feedItems.length === 0 ? ( 110 - <Center h={200}> 111 - <Text c="dimmed">No activity to show yet</Text> 112 - </Center> 113 - ) : ( 114 - <Stack gap="xl"> 115 - {feedItems.map((item) => ( 116 - <FeedItem key={item.id} item={item} /> 117 - ))} 118 - 119 - {hasMore && ( 120 - <Center> 121 - <Button 122 - onClick={handleLoadMore} 123 - loading={loadingMore} 124 - variant="outline" 125 - > 126 - {loadingMore ? 'Loading...' : 'Load More'} 127 - </Button> 128 - </Center> 129 - )} 130 - </Stack> 131 - )} 132 - </Stack> 133 - </Container> 134 - ); 3 + export default function Page() { 4 + return <MyFeedContainer />; 135 5 }
+27 -27
src/webapp/app/auth/complete/page.tsx
··· 14 14 const searchParams = useSearchParams(); 15 15 const { setTokens } = useAuth(); 16 16 17 - // Create API client instance 18 - const apiClient = new ApiClient( 19 - process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 20 - () => getAccessToken(), 21 - ); 22 - 23 17 useEffect(() => { 18 + // Create API client instance 19 + const apiClient = new ApiClient( 20 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 21 + () => getAccessToken(), 22 + ); 23 + 24 24 const accessToken = searchParams.get('accessToken'); 25 25 const refreshToken = searchParams.get('refreshToken'); 26 26 const error = searchParams.get('error'); ··· 35 35 return; 36 36 } 37 37 38 + const handleExtensionTokenGeneration = async () => { 39 + try { 40 + setMessage('Generating extension tokens...'); 41 + 42 + const tokens = await apiClient.generateExtensionTokens(); 43 + await ExtensionService.sendTokensToExtension(tokens); 44 + ExtensionService.clearExtensionTokensRequested(); 45 + 46 + setMessage('Extension tokens generated successfully!'); 47 + 48 + // Redirect to extension success page after successful extension token generation 49 + setTimeout(() => router.push('/extension/auth/complete'), 1000); 50 + } catch (extensionError: any) { 51 + console.error('Failed to generate extension tokens:', extensionError); 52 + ExtensionService.clearExtensionTokensRequested(); 53 + 54 + // Redirect to extension error page 55 + router.push('/extension/auth/error'); 56 + } 57 + }; 58 + 38 59 if (accessToken && refreshToken) { 39 60 // Store tokens using the auth context function 40 61 setTokens(accessToken, refreshToken); ··· 50 71 router.push('/login?error=Authentication failed'); 51 72 } 52 73 }, [router, searchParams, setTokens]); 53 - 54 - const handleExtensionTokenGeneration = async () => { 55 - try { 56 - setMessage('Generating extension tokens...'); 57 - 58 - const tokens = await apiClient.generateExtensionTokens(); 59 - await ExtensionService.sendTokensToExtension(tokens); 60 - ExtensionService.clearExtensionTokensRequested(); 61 - 62 - setMessage('Extension tokens generated successfully!'); 63 - 64 - // Redirect to extension success page after successful extension token generation 65 - setTimeout(() => router.push('/extension/auth/complete'), 1000); 66 - } catch (extensionError: any) { 67 - console.error('Failed to generate extension tokens:', extensionError); 68 - ExtensionService.clearExtensionTokensRequested(); 69 - 70 - // Redirect to extension error page 71 - router.push('/extension/auth/error'); 72 - } 73 - }; 74 74 75 75 return ( 76 76 <Stack align="center">
+1 -1
src/webapp/app/login/page.tsx
··· 44 44 clearTimeout(timeoutId); 45 45 } 46 46 }; 47 - }, [isAuthenticated, router]); 47 + }, [isAuthenticated, router, isExtensionLogin]); 48 48 49 49 if (isLoading) { 50 50 return (
+20 -8
src/webapp/components/AddToCollectionModal.tsx
··· 1 1 'use client'; 2 2 3 - import { useState, useEffect, useMemo } from 'react'; 3 + import { useState, useEffect, useMemo, useCallback } from 'react'; 4 4 import { getAccessToken } from '@/services/auth'; 5 5 import { ApiClient } from '@/api-client/ApiClient'; 6 6 import { Button, Group, Modal, Stack, Text } from '@mantine/core'; ··· 47 47 return card.collections || []; 48 48 }, [card]); 49 49 50 - useEffect(() => { 51 - if (isOpen) { 52 - fetchCard(); 53 - } 54 - }, [isOpen, cardId]); 50 + const fetchCard = useCallback(async () => { 51 + // Create API client instance 52 + const apiClient = new ApiClient( 53 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 54 + () => getAccessToken(), 55 + ); 55 56 56 - const fetchCard = async () => { 57 57 try { 58 58 setLoading(true); 59 59 setError(''); ··· 65 65 } finally { 66 66 setLoading(false); 67 67 } 68 - }; 68 + }, [cardId]); 69 + 70 + useEffect(() => { 71 + if (isOpen) { 72 + fetchCard(); 73 + } 74 + }, [isOpen, cardId, fetchCard]); 69 75 70 76 const handleSubmit = async () => { 71 77 if (selectedCollectionIds.length === 0) { ··· 77 83 setError(''); 78 84 79 85 try { 86 + // Create API client instance 87 + const apiClient = new ApiClient( 88 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 89 + () => getAccessToken(), 90 + ); 91 + 80 92 // Add card to all selected collections in a single request 81 93 await apiClient.addCardToCollection({ 82 94 cardId,
+1 -1
src/webapp/components/CreateCollectionModal.tsx
··· 82 82 if (isOpen && initialName !== form.getValues().name) { 83 83 form.setFieldValue('name', initialName); 84 84 } 85 - }, [isOpen, initialName]); 85 + }, [isOpen, initialName, form]); 86 86 87 87 return ( 88 88 <Modal
+2
src/webapp/features/cards/components/urlCardActions/UrlCardActions.tsx
··· 1 + 'use client'; 2 + 1 3 import { UrlCardView } from '@/api-client/types'; 2 4 import { AddToCollectionModal } from '@/components/AddToCollectionModal'; 3 5 import EditNoteDrawer from '@/features/notes/components/editNoteDrawer/EditNoteDrawer';
+70 -22
src/webapp/features/cards/containers/myCardsContainer/MyCardsContainer.tsx
··· 1 1 'use client'; 2 2 3 - import { Container, Grid, Stack, Title, Button, Text } from '@mantine/core'; 3 + import { 4 + Container, 5 + Grid, 6 + Stack, 7 + Title, 8 + Button, 9 + Text, 10 + Center, 11 + } from '@mantine/core'; 4 12 import useMyCards from '../../lib/queries/useMyCards'; 5 13 import UrlCard from '@/features/cards/components/urlCard/UrlCard'; 6 14 import { BiPlus } from 'react-icons/bi'; 7 - import Link from 'next/link'; 8 15 import AddCardDrawer from '../../components/addCardDrawer/AddCardDrawer'; 9 - import { useState } from 'react'; 16 + import { Fragment, useState } from 'react'; 17 + import MyCardsContainerError from './Error.MyCardsContainer'; 18 + import MyCardsContainerSkeleton from './Skeleton.MyCardsContainer'; 10 19 11 20 export default function MyCardsContainer() { 12 - const { data } = useMyCards(); 21 + const { 22 + data, 23 + error, 24 + fetchNextPage, 25 + hasNextPage, 26 + isFetchingNextPage, 27 + isPending, 28 + } = useMyCards(); 29 + 13 30 const [showAddDrawer, setShowAddDrawer] = useState(false); 31 + 32 + const allCards = data?.pages.flatMap((page) => page.cards ?? []) ?? []; 33 + 34 + if (isPending) { 35 + return <MyCardsContainerSkeleton />; 36 + } 37 + 38 + if (error) { 39 + return <MyCardsContainerError />; 40 + } 14 41 15 42 return ( 16 - <Container p={'xs'} size={'xl'}> 43 + <Container p="xs" size="xl"> 17 44 <Stack> 18 45 <Title order={1}>My Cards</Title> 19 - {data.cards.length > 0 ? ( 20 - <Grid gutter={'md'}> 21 - {data.cards.map((card) => ( 22 - <Grid.Col key={card.id} span={{ base: 12, xs: 6, sm: 4, lg: 3 }}> 23 - <UrlCard 24 - id={card.id} 25 - url={card.url} 26 - cardContent={card.cardContent} 27 - note={card.note} 28 - collections={card.collections} 29 - /> 30 - </Grid.Col> 31 - ))} 32 - </Grid> 46 + 47 + {allCards.length > 0 ? ( 48 + <Fragment> 49 + <Grid gutter="md"> 50 + {allCards.map((card) => ( 51 + <Grid.Col 52 + key={card.id} 53 + span={{ base: 12, xs: 6, sm: 4, lg: 3 }} 54 + > 55 + <UrlCard 56 + id={card.id} 57 + url={card.url} 58 + cardContent={card.cardContent} 59 + note={card.note} 60 + collections={card.collections} 61 + /> 62 + </Grid.Col> 63 + ))} 64 + </Grid> 65 + 66 + {hasNextPage && ( 67 + <Center> 68 + <Button 69 + onClick={() => fetchNextPage()} 70 + disabled={isFetchingNextPage} 71 + loading={isFetchingNextPage} 72 + variant="light" 73 + color="gray" 74 + mt="md" 75 + > 76 + Load More 77 + </Button> 78 + </Center> 79 + )} 80 + </Fragment> 33 81 ) : ( 34 - <Stack align="center" gap={'xs'}> 35 - <Text fz={'h3'} fw={600} c={'gray'}> 82 + <Stack align="center" gap="xs"> 83 + <Text fz="h3" fw={600} c="gray"> 36 84 No cards 37 85 </Text> 38 86 <Button 39 87 variant="light" 40 - color={'gray'} 88 + color="gray" 41 89 size="md" 42 90 rightSection={<BiPlus size={22} />} 43 91 onClick={() => setShowAddDrawer(true)}
+16 -5
src/webapp/features/cards/lib/queries/useMyCards.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 2 import { getAccessToken } from '@/services/auth'; 3 - import { useSuspenseQuery } from '@tanstack/react-query'; 3 + import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 4 4 5 5 interface Props { 6 6 limit?: number; ··· 12 12 () => getAccessToken(), 13 13 ); 14 14 15 - const myCards = useSuspenseQuery({ 16 - queryKey: ['my cards', props?.limit], 17 - queryFn: () => apiClient.getMyUrlCards({ limit: props?.limit ?? 10 }), 15 + const limit = props?.limit ?? 15; 16 + 17 + const query = useSuspenseInfiniteQuery({ 18 + queryKey: ['my cards', limit], 19 + initialPageParam: 1, 20 + queryFn: ({ pageParam = 1 }) => { 21 + return apiClient.getMyUrlCards({ limit, page: pageParam }); 22 + }, 23 + getNextPageParam: (lastPage) => { 24 + if (lastPage.pagination.hasMore) { 25 + return lastPage.pagination.currentPage + 1; 26 + } 27 + return undefined; 28 + }, 18 29 }); 19 30 20 - return myCards; 31 + return query; 21 32 }
+16 -9
src/webapp/features/collections/components/collectionSelector/CollectionSelector.tsx
··· 1 + 'use client'; 2 + 1 3 import { 2 4 ScrollArea, 3 5 Stack, ··· 73 75 return <CollectionSelectorError />; 74 76 } 75 77 76 - const hasCollections = data?.collections?.length > 0; 78 + const allCollections = 79 + data?.pages.flatMap((page) => page.collections ?? []) ?? []; 80 + 81 + const hasCollections = allCollections.length > 0; 77 82 const hasSelectedCollections = props.selectedCollections.length > 0; 78 83 79 84 return ( ··· 117 122 </Tabs.Tab> 118 123 </Tabs.List> 119 124 120 - {/* collections Panel */} 125 + {/* Collections Panel */} 121 126 <Tabs.Panel value="collections" my="xs" w="100%"> 122 127 <ScrollArea h={340} type="auto"> 123 128 <Stack gap="xs"> ··· 126 131 <Button 127 132 variant="light" 128 133 size="md" 129 - color={'grape'} 130 - radius={'lg'} 134 + color="grape" 135 + radius="lg" 131 136 leftSection={<BiPlus size={22} />} 132 137 onClick={() => setIsDrawerOpen(true)} 133 138 > ··· 156 161 ))} 157 162 </Fragment> 158 163 ) : hasCollections ? ( 159 - renderCollectionItems(data.collections) 164 + renderCollectionItems(allCollections) 160 165 ) : ( 161 166 <Stack align="center" gap="xs"> 162 167 <Text fz="lg" fw={600} c="gray"> ··· 176 181 </ScrollArea> 177 182 </Tabs.Panel> 178 183 179 - {/* selected Collections Panel */} 184 + {/* Selected Collections Panel */} 180 185 <Tabs.Panel value="selected" my="xs"> 181 186 <ScrollArea h={340} type="auto"> 182 187 <Stack gap="xs"> 183 - {props.selectedCollections.length > 0 ? ( 188 + {hasSelectedCollections ? ( 184 189 renderCollectionItems(props.selectedCollections) 185 190 ) : ( 186 191 <Alert color="gray" title="No collections selected" /> ··· 189 194 </ScrollArea> 190 195 </Tabs.Panel> 191 196 </Tabs> 192 - <Group justify="space-between" gap={'xs'} grow> 197 + 198 + <Group justify="space-between" gap="xs" grow> 193 199 <Button 194 200 variant="light" 195 - color={'gray'} 201 + color="gray" 196 202 size="md" 197 203 onClick={() => { 198 204 props.onSelectedCollectionsChange([]); ··· 218 224 </Stack> 219 225 </Container> 220 226 </Drawer> 227 + 221 228 <CreateCollectionDrawer 222 229 key={search} 223 230 isOpen={isDrawerOpen}
+10 -8
src/webapp/features/collections/components/collectionsNavList/CollectionsNavList.tsx
··· 8 8 import CreateCollectionShortcut from '../createCollectionShortcut/CreateCollectionShortcut'; 9 9 10 10 export default function CollectionsNavList() { 11 - const { data, error } = useCollections(); 11 + const { data, error } = useCollections({ limit: 30 }); 12 12 const [opened, toggleMenu] = useToggle([true, false]); 13 13 14 14 if (error) { 15 15 return <CollectionsNavListError />; 16 16 } 17 + 18 + const collections = 19 + data?.pages.flatMap((page) => page.collections ?? []) ?? []; 17 20 18 21 return ( 19 22 <NavLink ··· 24 27 opened={opened} 25 28 onClick={() => toggleMenu()} 26 29 > 27 - {/* Self-contained shortcut to prevent won't re-render this whole list when interacted with */} 28 30 <CreateCollectionShortcut /> 29 31 30 32 <NavLink 31 33 component={Link} 32 34 href="/collections" 33 - label={'View all'} 35 + label="View all" 34 36 variant="subtle" 35 37 c="blue" 36 38 leftSection={<BiRightArrowAlt size={25} />} 37 39 /> 38 40 39 41 <Stack gap={0}> 40 - {data.collections.map((c) => ( 42 + {collections.map((collection) => ( 41 43 <CollectionNavItem 42 - key={c.id} 43 - name={c.name} 44 - url={`/collections/${c.id}`} 45 - cardCount={c.cardCount} 44 + key={collection.id} 45 + name={collection.name} 46 + url={`/collections/${collection.id}`} 47 + cardCount={collection.cardCount} 46 48 /> 47 49 ))} 48 50 </Stack>
+82 -45
src/webapp/features/collections/containers/collectionContainer/CollectionContainer.tsx
··· 1 1 'use client'; 2 2 3 3 import { 4 - ActionIcon, 5 4 Anchor, 6 5 Box, 7 6 Button, 8 7 Container, 9 8 Grid, 10 9 Group, 11 - Menu, 12 10 Stack, 13 11 Text, 14 12 Title, 13 + Center, 14 + Loader, 15 15 } from '@mantine/core'; 16 16 import useCollection from '../../lib/queries/useCollection'; 17 17 import UrlCard from '@/features/cards/components/urlCard/UrlCard'; 18 - import { BsTrash2Fill, BsThreeDots, BsPencilFill } from 'react-icons/bs'; 19 18 import Link from 'next/link'; 20 19 import { useState } from 'react'; 21 - import EditCollectionDrawer from '../../components/editCollectionDrawer/EditCollectionDrawer'; 22 20 import { BiPlus } from 'react-icons/bi'; 23 - import DeleteCollectionModal from '../../components/deleteCollectionModal/DeleteCollectionModal'; 24 21 import AddCardDrawer from '@/features/cards/components/addCardDrawer/AddCardDrawer'; 25 22 import CollectionActions from '../../components/collectionActions/CollectionActions'; 23 + import CollectionContainerError from './Error.CollectionContainer'; 24 + import CollectionContainerSkeleton from './Skeleton.CollectionContainer'; 26 25 27 26 interface Props { 28 27 id: string; 29 28 } 30 29 31 - export default function CollectionContainer(props: Props) { 32 - const { data } = useCollection({ id: props.id }); 30 + export default function CollectionContainer({ id }: Props) { 31 + const { 32 + data, 33 + isPending, 34 + error, 35 + fetchNextPage, 36 + hasNextPage, 37 + isFetchingNextPage, 38 + } = useCollection({ id }); 39 + 33 40 const [showAddDrawer, setShowAddDrawer] = useState(false); 41 + 42 + if (isPending) { 43 + return <CollectionContainerSkeleton />; 44 + } 45 + 46 + if (error) { 47 + return <CollectionContainerError />; 48 + } 49 + 50 + const firstPage = data.pages[0]; 51 + const allCards = data.pages.flatMap((page) => page.urlCards ?? []); 34 52 35 53 return ( 36 - <Container p={'xs'} size={'xl'}> 54 + <Container p="xs" size="xl"> 37 55 <Stack justify="flex-start"> 38 56 <Group justify="space-between" align="start"> 39 57 <Stack gap={0}> 40 - <Text fw={700} c={'grape'}> 58 + <Text fw={700} c="grape"> 41 59 Collection 42 60 </Text> 43 61 <Title order={1} lh={0.8}> 44 - {data.name} 62 + {firstPage.name} 45 63 </Title> 46 - {data.description && ( 47 - <Text c={'gray'} mt={'lg'}> 48 - {data.description} 64 + {firstPage.description && ( 65 + <Text c="gray" mt="lg"> 66 + {firstPage.description} 49 67 </Text> 50 68 )} 51 69 </Stack> 52 70 53 71 <Stack> 54 - <Text fw={600} c={'gray.7'}> 72 + <Text fw={600} c="gray.7"> 55 73 By{' '} 56 74 <Anchor 57 75 component={Link} 58 - href={`/profile/${data.author.handle}`} 76 + href={`/profile/${firstPage.author.handle}`} 59 77 fw={700} 60 - c={'blue'} 78 + c="blue" 61 79 > 62 - {data.author.name} 80 + {firstPage.author.name} 63 81 </Anchor> 64 82 </Text> 65 83 </Stack> ··· 67 85 68 86 <Group justify="end"> 69 87 <CollectionActions 70 - id={props.id} 71 - name={data.name} 72 - description={data.description} 73 - authorHandle={data.author.handle} 88 + id={id} 89 + name={firstPage.name} 90 + description={firstPage.description} 91 + authorHandle={firstPage.author.handle} 74 92 /> 75 93 </Group> 76 94 77 - {data.urlCards.length > 0 ? ( 78 - <Grid gutter={'md'}> 79 - {data.urlCards.map((card) => ( 80 - <Grid.Col key={card.id} span={{ base: 12, xs: 6, sm: 4, lg: 3 }}> 81 - <UrlCard 82 - id={card.id} 83 - url={card.url} 84 - cardContent={card.cardContent} 85 - note={card.note} 86 - currentCollection={{ 87 - id: data.id, 88 - name: data.name, 89 - authorId: data.author.id, 90 - }} 91 - /> 92 - </Grid.Col> 93 - ))} 94 - </Grid> 95 + {allCards.length > 0 ? ( 96 + <> 97 + <Grid gutter="md"> 98 + {allCards.map((card) => ( 99 + <Grid.Col 100 + key={card.id} 101 + span={{ base: 12, xs: 6, sm: 4, lg: 3 }} 102 + > 103 + <UrlCard 104 + id={card.id} 105 + url={card.url} 106 + cardContent={card.cardContent} 107 + note={card.note} 108 + currentCollection={{ 109 + id: firstPage.id, 110 + name: firstPage.name, 111 + authorId: firstPage.author.id, 112 + }} 113 + /> 114 + </Grid.Col> 115 + ))} 116 + </Grid> 117 + 118 + {hasNextPage && ( 119 + <Center> 120 + <Button 121 + mt="md" 122 + variant="light" 123 + color="gray" 124 + onClick={() => fetchNextPage()} 125 + loading={isFetchingNextPage} 126 + > 127 + {isFetchingNextPage ? 'Loading more...' : 'Load More'} 128 + </Button> 129 + </Center> 130 + )} 131 + </> 95 132 ) : ( 96 - <Stack align="center" gap={'xs'}> 97 - <Text fz={'h3'} fw={600} c={'gray'}> 133 + <Stack align="center" gap="xs"> 134 + <Text fz="h3" fw={600} c="gray"> 98 135 No cards 99 136 </Text> 100 137 <Button 101 138 variant="light" 102 - color={'gray'} 139 + color="gray" 103 140 size="md" 104 141 rightSection={<BiPlus size={22} />} 105 142 onClick={() => setShowAddDrawer(true)} ··· 115 152 isOpen={showAddDrawer} 116 153 onClose={() => setShowAddDrawer(false)} 117 154 selectedCollection={{ 118 - id: data.id, 119 - name: data.name, 120 - cardCount: data.urlCards.length, 155 + id: firstPage.id, 156 + name: firstPage.name, 157 + cardCount: allCards.length, 121 158 }} 122 159 /> 123 160 </Box>
+39 -15
src/webapp/features/collections/containers/collectionsContainer/CollectionsContainer.tsx
··· 7 7 Title, 8 8 Text, 9 9 SimpleGrid, 10 + Center, 10 11 } from '@mantine/core'; 11 12 import useCollections from '../../lib/queries/useCollections'; 12 13 import { BiPlus } from 'react-icons/bi'; ··· 15 16 import CreateCollectionDrawer from '../../components/createCollectionDrawer/CreateCollectionDrawer'; 16 17 17 18 export default function CollectionsContainer() { 18 - const { data } = useCollections(); 19 + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = 20 + useCollections(); 21 + 19 22 const [isDrawerOpen, setIsDrawerOpen] = useState(false); 20 23 24 + const collections = 25 + data?.pages.flatMap((page) => page.collections ?? []) ?? []; 26 + 21 27 return ( 22 - <Container p={'xs'} size={'xl'}> 28 + <Container p="xs" size="xl"> 23 29 <Stack> 24 30 <Title order={1}>Collections</Title> 25 31 26 - {data.collections.length > 0 ? ( 27 - <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing={'md'}> 28 - {data.collections.map((c) => ( 29 - <CollectionCard key={c.id} collection={c} /> 30 - ))} 31 - </SimpleGrid> 32 + {collections.length > 0 ? ( 33 + <Stack> 34 + <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md"> 35 + {collections.map((collection) => ( 36 + <CollectionCard key={collection.id} collection={collection} /> 37 + ))} 38 + </SimpleGrid> 39 + 40 + {hasNextPage && ( 41 + <Center> 42 + <Button 43 + onClick={() => fetchNextPage()} 44 + disabled={isFetchingNextPage} 45 + loading={isFetchingNextPage} 46 + variant="light" 47 + color="gray" 48 + mt="md" 49 + > 50 + Load More 51 + </Button> 52 + </Center> 53 + )} 54 + </Stack> 32 55 ) : ( 33 - <Stack align="center" gap={'xs'}> 34 - <Text fz={'h3'} fw={600} c={'gray'}> 56 + <Stack align="center" gap="xs"> 57 + <Text fz="h3" fw={600} c="gray"> 35 58 No collections 36 59 </Text> 37 60 <Button 38 61 onClick={() => setIsDrawerOpen(true)} 39 62 variant="light" 40 - color={'gray'} 63 + color="gray" 41 64 size="md" 42 65 rightSection={<BiPlus size={22} />} 43 66 > 44 67 Create your first collection 45 68 </Button> 46 - <CreateCollectionDrawer 47 - isOpen={isDrawerOpen} 48 - onClose={() => setIsDrawerOpen(false)} 49 - /> 50 69 </Stack> 51 70 )} 52 71 </Stack> 72 + 73 + <CreateCollectionDrawer 74 + isOpen={isDrawerOpen} 75 + onClose={() => setIsDrawerOpen(false)} 76 + /> 53 77 </Container> 54 78 ); 55 79 }
+14 -10
src/webapp/features/collections/lib/queries/useCollection.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 2 import { getAccessToken } from '@/services/auth'; 3 - import { useSuspenseQuery } from '@tanstack/react-query'; 3 + import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 4 4 5 5 interface Props { 6 6 id: string; 7 + limit?: number; 7 8 } 8 9 9 10 export default function useCollection(props: Props) { ··· 12 13 () => getAccessToken(), 13 14 ); 14 15 15 - // TODO: replace with infinite suspense query 16 - const collection = useSuspenseQuery({ 17 - queryKey: ['collection', props.id], 18 - queryFn: () => 19 - apiClient.getCollectionPage(props.id, { 20 - limit: 50, 21 - }), 22 - }); 16 + const limit = props.limit ?? 20; 23 17 24 - return collection; 18 + return useSuspenseInfiniteQuery({ 19 + queryKey: ['collection', props.id, limit], 20 + initialPageParam: 1, 21 + queryFn: ({ pageParam }) => 22 + apiClient.getCollectionPage(props.id, { limit, page: pageParam }), 23 + getNextPageParam: (lastPage) => { 24 + return lastPage.pagination.hasMore 25 + ? lastPage.pagination.currentPage + 1 26 + : undefined; 27 + }, 28 + }); 25 29 }
+13 -6
src/webapp/features/collections/lib/queries/useCollections.tsx
··· 1 1 import { ApiClient } from '@/api-client/ApiClient'; 2 2 import { getAccessToken } from '@/services/auth'; 3 - import { useSuspenseQuery } from '@tanstack/react-query'; 3 + import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 4 4 5 5 interface Props { 6 6 limit?: number; ··· 12 12 () => getAccessToken(), 13 13 ); 14 14 15 - const collections = useSuspenseQuery({ 16 - queryKey: ['collections', props?.limit], 17 - queryFn: () => apiClient.getMyCollections({ limit: props?.limit ?? 50 }), 15 + const limit = props?.limit ?? 15; 16 + 17 + return useSuspenseInfiniteQuery({ 18 + queryKey: ['collections', limit], 19 + initialPageParam: 1, 20 + queryFn: ({ pageParam }) => 21 + apiClient.getMyCollections({ limit, page: pageParam }), 22 + getNextPageParam: (lastPage) => { 23 + return lastPage.pagination.hasMore 24 + ? lastPage.pagination.currentPage + 1 25 + : undefined; 26 + }, 18 27 }); 19 - 20 - return collections; 21 28 }
+5
src/webapp/features/feeds/containers/myFeedContainer/Error.MyFeedContainer.tsx
··· 1 + import { Alert } from '@mantine/core'; 2 + 3 + export default function MyFeedContainerError() { 4 + return <Alert variant="white" color="red" title="Could not load feed" />; 5 + }
+62
src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx
··· 1 + 'use client'; 2 + 3 + import useMyFeed from '@/features/feeds/lib/queries/useMyFeed'; 4 + import FeedItem from '@/features/feeds/components/feedItem/FeedItem'; 5 + import { Button, Stack, Title, Text, Center, Container } from '@mantine/core'; 6 + import MyFeedContainerSkeleton from './Skeleton.MyFeedContainer'; 7 + import MyFeedContainerError from './Error.MyFeedContainer'; 8 + 9 + export default function MyFeedContainer() { 10 + const { 11 + data, 12 + error, 13 + isPending, 14 + fetchNextPage, 15 + hasNextPage, 16 + isFetchingNextPage, 17 + } = useMyFeed(); 18 + 19 + const allActivities = 20 + data?.pages.flatMap((page) => page.activities ?? []) ?? []; 21 + 22 + if (isPending) { 23 + return <MyFeedContainerSkeleton />; 24 + } 25 + 26 + if (error) { 27 + return <MyFeedContainerError />; 28 + } 29 + 30 + return ( 31 + <Container p="xs" size="xl"> 32 + <Stack> 33 + <Title order={2}>Explore</Title> 34 + 35 + {allActivities.length === 0 ? ( 36 + <Center h={200}> 37 + <Text c="dimmed">No activity to show yet</Text> 38 + </Center> 39 + ) : ( 40 + <Stack gap="xl"> 41 + {allActivities.map((item) => ( 42 + <FeedItem key={item.id} item={item} /> 43 + ))} 44 + 45 + {hasNextPage && ( 46 + <Center> 47 + <Button 48 + onClick={() => fetchNextPage()} 49 + loading={isFetchingNextPage} 50 + variant="light" 51 + color="gray" 52 + > 53 + Load More 54 + </Button> 55 + </Center> 56 + )} 57 + </Stack> 58 + )} 59 + </Stack> 60 + </Container> 61 + ); 62 + }
+5
src/webapp/features/feeds/containers/myFeedContainer/Skeleton.MyFeedContainer.tsx
··· 1 + import { Loader } from '@mantine/core'; 2 + 3 + export default function MyFeedContainerSkeleton() { 4 + return <Loader />; 5 + }
+32
src/webapp/features/feeds/lib/queries/useMyFeed.tsx
··· 1 + import { ApiClient } from '@/api-client/ApiClient'; 2 + import { getAccessToken } from '@/services/auth'; 3 + import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 4 + 5 + interface Props { 6 + limit?: number; 7 + } 8 + 9 + export default function useMyFeed(props?: Props) { 10 + const apiClient = new ApiClient( 11 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 12 + () => getAccessToken(), 13 + ); 14 + 15 + const limit = props?.limit ?? 15; 16 + 17 + const query = useSuspenseInfiniteQuery({ 18 + queryKey: ['my feed', limit], 19 + initialPageParam: 1, 20 + queryFn: ({ pageParam = 1 }) => { 21 + return apiClient.getGlobalFeed({ limit, page: pageParam }); 22 + }, 23 + getNextPageParam: (lastPage) => { 24 + if (lastPage.pagination.hasMore) { 25 + return lastPage.pagination.currentPage + 1; 26 + } 27 + return undefined; 28 + }, 29 + }); 30 + 31 + return query; 32 + }
+34 -24
src/webapp/features/library/containers/libraryContainer/LibraryContainer.tsx
··· 23 23 import AddCardDrawer from '@/features/cards/components/addCardDrawer/AddCardDrawer'; 24 24 25 25 export default function LibraryContainer() { 26 - const { data: CollectionsData } = useCollections({ limit: 4 }); 26 + const { data: collectionsData } = useCollections({ limit: 4 }); 27 27 const { data: myCardsData } = useMyCards({ limit: 4 }); 28 + 28 29 const [showCollectionDrawer, setShowCollectionDrawer] = useState(false); 29 30 const [showAddDrawer, setShowAddDrawer] = useState(false); 30 31 32 + const collections = 33 + collectionsData?.pages.flatMap((page) => page.collections) ?? []; 34 + const cards = myCardsData?.pages.flatMap((page) => page.cards) ?? []; 35 + 31 36 return ( 32 - <Container p={'xs'} size={'xl'}> 33 - <Stack gap={'xl'}> 37 + <Container p="xs" size="xl"> 38 + <Stack gap="xl"> 34 39 <Title order={1}>Library</Title> 35 40 36 41 <Stack gap={50}> 42 + {/* Collections */} 37 43 <Stack> 38 44 <Group justify="space-between"> 39 - <Group gap={'xs'}> 45 + <Group gap="xs"> 40 46 <BiCollection size={22} /> 41 47 <Title order={2}>Collections</Title> 42 48 </Group> 43 - <Anchor component={Link} href={'/collections'} c="blue" fw={600}> 49 + <Anchor component={Link} href="/collections" c="blue" fw={600}> 44 50 View all 45 51 </Anchor> 46 52 </Group> 47 53 48 - {CollectionsData.collections.length > 0 ? ( 49 - <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing={'md'}> 50 - {CollectionsData.collections.map((c) => ( 51 - <CollectionCard key={c.id} collection={c} /> 54 + {collections.length > 0 ? ( 55 + <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md"> 56 + {collections.map((collection) => ( 57 + <CollectionCard key={collection.id} collection={collection} /> 52 58 ))} 53 59 </SimpleGrid> 54 60 ) : ( 55 - <Stack align="center" gap={'xs'}> 56 - <Text fz={'h3'} fw={600} c={'gray'}> 61 + <Stack align="center" gap="xs"> 62 + <Text fz="h3" fw={600} c="gray"> 57 63 No collections 58 64 </Text> 59 65 <Button 60 66 onClick={() => setShowCollectionDrawer(true)} 61 67 variant="light" 62 - color={'gray'} 68 + color="gray" 63 69 size="md" 64 70 rightSection={<BiPlus size={22} />} 65 71 > ··· 69 75 )} 70 76 </Stack> 71 77 78 + {/* Cards */} 72 79 <Stack> 73 80 <Group justify="space-between"> 74 - <Group gap={'xs'}> 81 + <Group gap="xs"> 75 82 <FaRegNoteSticky size={22} /> 76 83 <Title order={2}>My Cards</Title> 77 84 </Group> 78 - <Anchor component={Link} href={'/my-cards'} c="blue" fw={600}> 85 + <Anchor component={Link} href="/my-cards" c="blue" fw={600}> 79 86 View all 80 87 </Anchor> 81 88 </Group> 82 - {myCardsData.cards.length > 0 ? ( 83 - <Grid gutter={'md'}> 84 - {myCardsData.cards.map((card) => ( 89 + 90 + {cards.length > 0 ? ( 91 + <Grid gutter="md"> 92 + {cards.map((card) => ( 85 93 <Grid.Col 86 94 key={card.id} 87 95 span={{ base: 12, xs: 6, sm: 4, lg: 3 }} ··· 97 105 ))} 98 106 </Grid> 99 107 ) : ( 100 - <Stack align="center" gap={'xs'}> 101 - <Text fz={'h3'} fw={600} c={'gray'}> 108 + <Stack align="center" gap="xs"> 109 + <Text fz="h3" fw={600} c="gray"> 102 110 No cards 103 111 </Text> 104 112 <Button 105 113 variant="light" 106 - color={'gray'} 114 + color="gray" 107 115 size="md" 108 116 rightSection={<BiPlus size={22} />} 109 117 onClick={() => setShowAddDrawer(true)} 110 118 > 111 119 Add your first card 112 120 </Button> 113 - <AddCardDrawer 114 - isOpen={showAddDrawer} 115 - onClose={() => setShowAddDrawer(false)} 116 - /> 117 121 </Stack> 118 122 )} 119 123 </Stack> 120 124 </Stack> 121 125 </Stack> 126 + 127 + {/* Drawers */} 122 128 <CreateCollectionDrawer 123 129 isOpen={showCollectionDrawer} 124 130 onClose={() => setShowCollectionDrawer(false)} 131 + /> 132 + <AddCardDrawer 133 + isOpen={showAddDrawer} 134 + onClose={() => setShowAddDrawer(false)} 125 135 /> 126 136 </Container> 127 137 );