A social knowledge tool for researchers built on ATProto

Merge branch 'development' into feature/semble-page-endpoints

+225 -505
+1 -1
src/modules/cards/tests/utils/FakeCardPublisher.ts
··· 29 29 30 30 const cardId = card.cardId.getStringValue(); 31 31 // Simulate generating an AT URI based on curator DID and collection/rkey 32 - const fakeUri = `at://${curatorId.value}/network.cosmik.card/${cardId}`; 32 + const fakeUri = `at://${curatorId.value}/network.cosmik.dev.card/${cardId}`; 33 33 const fakeCid = `fake-cid-${cardId}`; 34 34 const publishedRecordId = PublishedRecordId.create({ 35 35 uri: fakeUri,
+1 -1
src/modules/cards/tests/utils/FakeCollectionPublisher.ts
··· 33 33 const fakeDid = process.env.BSKY_DID || 'did:plc:rlknsba2qldjkicxsmni3vyn'; 34 34 35 35 // Simulate publishing the collection record itself 36 - const fakeCollectionUri = `at://${fakeDid}/network.cosmik.collection/${collectionId}`; 36 + const fakeCollectionUri = `at://${fakeDid}/network.cosmik.dev.collection/${collectionId}`; 37 37 const fakeCollectionCid = `fake-collection-cid-${collectionId}`; 38 38 39 39 const collectionRecord = PublishedRecordId.create({
+1 -1
src/modules/cards/tests/utils/builders/CollectionBuilder.ts
··· 57 57 withPublished(published: boolean): CollectionBuilder { 58 58 if (published) { 59 59 // Create a fake published record ID when marking as published 60 - const fakeUri = `at://fake-did/network.cosmik.collection/${this._id?.toString() || 'fake-id'}`; 60 + const fakeUri = `at://fake-did/network.cosmik.dev.collection/${this._id?.toString() || 'fake-id'}`; 61 61 const fakeCid = `fake-collection-cid-${this._id?.toString() || 'fake-id'}`; 62 62 this._publishedRecordId = PublishedRecordId.create({ 63 63 uri: fakeUri,
+15 -3
src/shared/infrastructure/database/migrations/0004_brainy_rocket_racer.sql
··· 1 - ALTER TABLE "cards" RENAME COLUMN "original_published_record_id" TO "published_record_id";--> statement-breakpoint 2 - ALTER TABLE "cards" DROP CONSTRAINT "cards_original_published_record_id_published_records_id_fk"; 1 + TRUNCATE TABLE "collection_cards", 2 + "collection_collaborators", 3 + "library_memberships", 4 + "collections", 5 + "cards", 6 + "published_records", 7 + "feed_activities" CASCADE; 3 8 --> statement-breakpoint 4 - ALTER TABLE "cards" ADD COLUMN "author_id" text NOT NULL;--> statement-breakpoint 9 + ALTER TABLE "cards" 10 + RENAME COLUMN "original_published_record_id" TO "published_record_id"; 11 + --> statement-breakpoint 12 + ALTER TABLE "cards" 13 + DROP CONSTRAINT "cards_original_published_record_id_published_records_id_fk"; 14 + --> statement-breakpoint 15 + ALTER TABLE "cards" ADD COLUMN "author_id" text NOT NULL; 16 + --> statement-breakpoint 5 17 ALTER TABLE "cards" ADD CONSTRAINT "cards_published_record_id_published_records_id_fk" FOREIGN KEY ("published_record_id") REFERENCES "public"."published_records"("id") ON DELETE no action ON UPDATE no action;
+4 -6
src/webapp/components/UrlCardForm.tsx
··· 47 47 // URL metadata hook 48 48 const { 49 49 metadata, 50 - existingCard, 50 + existingCardCollections, 51 51 loading: metadataLoading, 52 52 error: metadataError, 53 53 } = useUrlMetadata({ ··· 58 58 59 59 // Get existing collections for this card (filtered by current user) 60 60 const existingCollections = useMemo(() => { 61 - if (!existingCard || !userId) return []; 62 - return existingCard.collections.filter( 63 - (collection) => collection.authorId === userId, 64 - ); 65 - }, [existingCard, userId]); 61 + if (!existingCardCollections || !userId) return []; 62 + return existingCardCollections.map((c) => ({ ...c, authorId: userId })); 63 + }, [existingCardCollections, userId]); 66 64 67 65 const handleSubmit = async (e: React.FormEvent) => { 68 66 e.preventDefault();
+47
src/webapp/components/contentDisplay/infiniteScroll/InfiniteScroll.tsx
··· 1 + 'use client'; 2 + 3 + import { ReactNode, useEffect, startTransition, useRef } from 'react'; 4 + import { Center, Button, Stack } from '@mantine/core'; 5 + import { useIntersection } from '@mantine/hooks'; 6 + 7 + interface Props { 8 + children: ReactNode; 9 + dataLength: number; 10 + hasMore: boolean; 11 + isInitialLoading: boolean; 12 + isLoading: boolean; 13 + loadMore: () => void; 14 + loader?: ReactNode; 15 + manualLoadButton?: boolean; 16 + } 17 + 18 + export default function InfiniteScroll(props: Props) { 19 + const containerRef = useRef<HTMLDivElement>(null); 20 + const { ref, entry } = useIntersection({ 21 + root: containerRef.current, 22 + threshold: 0, 23 + }); 24 + 25 + useEffect(() => { 26 + startTransition(() => { 27 + if (entry?.isIntersecting && props.hasMore && !props.isLoading) { 28 + props.loadMore(); 29 + } 30 + }); 31 + }, [entry?.isIntersecting, props.hasMore, props.isLoading, props.loadMore]); 32 + 33 + return ( 34 + <Stack> 35 + {props.children} 36 + {props.isLoading && props.loader} 37 + 38 + <Center ref={ref}> 39 + {!props.isLoading && props.hasMore && props.manualLoadButton && ( 40 + <Button loading={props.isLoading} onClick={props.loadMore}> 41 + Load more 42 + </Button> 43 + )} 44 + </Center> 45 + </Stack> 46 + ); 47 + }
+1 -1
src/webapp/components/navigation/navbar/Navbar.tsx
··· 30 30 const { data: profile } = useMyProfile(); 31 31 32 32 return ( 33 - <AppShellNavbar px={'xs'} pb={'md'} pt={'xs'} style={{ zIndex: 3 }}> 33 + <AppShellNavbar p={'xs'} style={{ zIndex: 3 }}> 34 34 <Group justify="space-between" ml={'sm'}> 35 35 <Anchor component={Link} href={'/home'}> 36 36 <Image src={SembleLogo.src} alt="Semble logo" w={20.84} h={28} />
+11 -17
src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx
··· 15 15 Alert, 16 16 } from '@mantine/core'; 17 17 import { useDebouncedValue } from '@mantine/hooks'; 18 + import { notifications } from '@mantine/notifications'; 18 19 import { Fragment, useState } from 'react'; 19 20 import { IoSearch } from 'react-icons/io5'; 20 21 import { BiPlus } from 'react-icons/bi'; 21 - import useMyCollections from '../../../collections/lib/queries/useMyCollections'; 22 - import useCard from '@/features/cards/lib/queries/useGetCard'; 23 - import { notifications } from '@mantine/notifications'; 24 22 import CollectionSelectorError from '../../../collections/components/collectionSelector/Error.CollectionSelector'; 25 23 import CollectionSelectorItemList from '../../../collections/components/collectionSelectorItemList/CollectionSelectorItemList'; 26 24 import CreateCollectionDrawer from '../../../collections/components/createCollectionDrawer/CreateCollectionDrawer'; 27 25 import CardToBeAddedPreview from './CardToBeAddedPreview'; 28 26 import useAddCardToLibrary from '../../lib/mutations/useAddCardToLibrary'; 29 - import useGetLibrariesForCard from '../../lib/queries/useGetLibrariesForcard'; 30 - import { useAuth } from '@/hooks/useAuth'; 27 + import useGetCardFromMyLibrary from '../../lib/queries/useGetCardFromMyLibrary'; 28 + import useMyCollections from '../../../collections/lib/queries/useMyCollections'; 31 29 32 30 interface Props { 33 31 isOpen: boolean; ··· 37 35 } 38 36 39 37 export default function AddCardToModal(props: Props) { 40 - const { user, isLoading: isLoadingUser } = useAuth(); 41 38 const [isDrawerOpen, setIsDrawerOpen] = useState(false); 42 39 43 40 const [search, setSearch] = useState<string>(''); 44 41 const [debouncedSearch] = useDebouncedValue(search, 200); 45 42 const searchedCollections = useCollectionSearch({ query: debouncedSearch }); 46 43 47 - const libraries = useGetLibrariesForCard({ id: props.cardId }); 48 - const isInUserLibrary = libraries.data.users.some((u) => u.id === user?.id); 44 + const addCardToLibrary = useAddCardToLibrary(); 49 45 50 - const addCardToLibrary = useAddCardToLibrary(); 51 - const card = useCard({ id: props.cardId }); 46 + const cardStaus = useGetCardFromMyLibrary({ url: props.cardContent.url }); 52 47 const { data, error } = useMyCollections(); 53 48 const [selectedCollections, setSelectedCollections] = useState< 54 49 SelectableCollectionItem[] ··· 73 68 data?.pages.flatMap((page) => page.collections ?? []) ?? []; 74 69 75 70 const collectionsWithCard = allCollections.filter((c) => 76 - card.data?.collections.some((col) => col.id === c.id), 71 + cardStaus.data.collections?.some((col) => col.id === c.id), 77 72 ); 78 73 79 74 const collectionsWithoutCard = allCollections.filter( 80 75 (c) => !collectionsWithCard.some((col) => col.id === c.id), 81 76 ); 77 + 78 + const isInUserLibrary = collectionsWithCard.length > 0; 82 79 83 80 const hasCollections = allCollections.length > 0; 84 81 const hasSelectedCollections = selectedCollections.length > 0; ··· 88 85 89 86 addCardToLibrary.mutate( 90 87 { 91 - cardId: props.cardId, 88 + url: props.cardContent.url, 92 89 collectionIds: selectedCollections.map((c) => c.id), 93 90 }, 94 91 { ··· 278 275 onClick={handleAddCard} 279 276 // disabled when: 280 277 // user already has the card in a collection (and therefore in library) 281 - // and when it's already in library and no new collection is selected yet 282 - disabled={ 283 - isLoadingUser || 284 - (isInUserLibrary && selectedCollections.length === 0) 285 - } 278 + // and no new collection is selected yet 279 + disabled={isInUserLibrary && selectedCollections.length === 0} 286 280 loading={addCardToLibrary.isPending} 287 281 > 288 282 Add
+21 -13
src/webapp/features/cards/components/addCardToModal/CardToBeAddedPreview.tsx
··· 11 11 Anchor, 12 12 } from '@mantine/core'; 13 13 import Link from 'next/link'; 14 - import { GetCollectionsResponse, UrlCardView } from '@/api-client/types'; 14 + import { 15 + GetUrlStatusForMyLibraryResponse, 16 + UrlCardView, 17 + } from '@/api-client/types'; 15 18 import { BiCollection } from 'react-icons/bi'; 16 19 import { LuLibrary } from 'react-icons/lu'; 17 20 import { getDomain } from '@/lib/utils/link'; 18 21 import useMyProfile from '@/features/profile/lib/queries/useMyProfile'; 22 + import { getRecordKey } from '@/lib/utils/atproto'; 23 + import { Fragment } from 'react'; 19 24 20 25 interface Props { 21 26 cardId: string; 22 27 cardContent: UrlCardView['cardContent']; 23 - collectionsWithCard: GetCollectionsResponse['collections']; 28 + collectionsWithCard: GetUrlStatusForMyLibraryResponse['collections']; 24 29 isInLibrary: boolean; 25 30 } 26 31 ··· 77 82 In Library 78 83 </Button> 79 84 )} 80 - {props.collectionsWithCard.length > 0 && ( 85 + {props.collectionsWithCard && props.collectionsWithCard.length > 0 && ( 81 86 <Menu shadow="sm"> 82 87 <Menu.Target> 83 88 <Button ··· 92 97 <Menu.Dropdown maw={380}> 93 98 <ScrollArea.Autosize mah={150} type="auto"> 94 99 {props.collectionsWithCard.map((c) => ( 95 - <Menu.Item 96 - key={c.id} 97 - component={Link} 98 - href={`/profile/${c.createdBy.handle}/collections/${c.id}`} 99 - target="_blank" 100 - c="blue" 101 - fw={600} 102 - > 103 - {c.name} 104 - </Menu.Item> 100 + <Fragment key={c.id}> 101 + {c.uri && ( 102 + <Menu.Item 103 + component={Link} 104 + href={`/profile/${profile.handle}/collections/${getRecordKey(c.uri)}`} 105 + target="_blank" 106 + c="blue" 107 + fw={600} 108 + > 109 + {c.name} 110 + </Menu.Item> 111 + )} 112 + </Fragment> 105 113 ))} 106 114 </ScrollArea.Autosize> 107 115 </Menu.Dropdown>
+11 -1
src/webapp/features/cards/components/urlCard/UrlCard.tsx
··· 8 8 Group, 9 9 Anchor, 10 10 AspectRatio, 11 + Skeleton, 11 12 } from '@mantine/core'; 12 13 import Link from 'next/link'; 13 14 import UrlCardActions from '../urlCardActions/UrlCardActions'; ··· 69 70 )} 70 71 </Group> 71 72 72 - <Suspense> 73 + <Suspense 74 + fallback={ 75 + <Group justify="space-between"> 76 + <Group gap={'xs'}> 77 + <Skeleton w={22} h={22} /> 78 + </Group> 79 + <Skeleton w={22} h={22} /> 80 + </Group> 81 + } 82 + > 73 83 <UrlCardActions 74 84 cardContent={props.cardContent} 75 85 id={props.id}
+30 -35
src/webapp/features/cards/containers/cardsContainer/CardsContainer.tsx
··· 1 1 'use client'; 2 2 3 - import { Container, Grid, Stack, Button, Text, Center } from '@mantine/core'; 3 + import { Center, Container, Grid, Loader, Stack } from '@mantine/core'; 4 4 import useCards from '../../lib/queries/useCards'; 5 5 import UrlCard from '@/features/cards/components/urlCard/UrlCard'; 6 6 import CardsContainerError from './Error.CardsContainer'; 7 7 import CardsContainerSkeleton from './Skeleton.CardsContainer'; 8 - import { Fragment, useState } from 'react'; 9 8 import ProfileEmptyTab from '@/features/profile/components/profileEmptyTab/ProfileEmptyTab'; 10 9 import { FaRegNoteSticky } from 'react-icons/fa6'; 10 + import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; 11 11 12 12 interface Props { 13 13 handle: string; ··· 43 43 44 44 return ( 45 45 <Container p="xs" size="xl"> 46 - <Stack> 47 - <Fragment> 48 - <Grid gutter="md"> 49 - {allCards.map((card) => ( 50 - <Grid.Col key={card.id} span={{ base: 12, xs: 6, sm: 4, lg: 3 }}> 51 - <UrlCard 52 - id={card.id} 53 - url={card.url} 54 - cardContent={card.cardContent} 55 - note={card.note} 56 - collections={card.collections} 57 - authorHandle={props.handle} 58 - /> 59 - </Grid.Col> 60 - ))} 61 - </Grid> 62 - 63 - {hasNextPage && ( 64 - <Center> 65 - <Button 66 - onClick={() => fetchNextPage()} 67 - disabled={isFetchingNextPage} 68 - loading={isFetchingNextPage} 69 - variant="light" 70 - color="gray" 71 - mt="md" 72 - > 73 - Load More 74 - </Button> 75 - </Center> 76 - )} 77 - </Fragment> 78 - </Stack> 46 + <InfiniteScroll 47 + dataLength={allCards.length} 48 + hasMore={!!hasNextPage} 49 + isInitialLoading={isPending} 50 + isLoading={isFetchingNextPage} 51 + loadMore={fetchNextPage} 52 + manualLoadButton={false} 53 + loader={ 54 + <Center> 55 + <Loader /> 56 + </Center> 57 + } 58 + > 59 + <Grid gutter="md"> 60 + {allCards.map((card) => ( 61 + <Grid.Col key={card.id} span={{ base: 12, xs: 6, sm: 4, lg: 3 }}> 62 + <UrlCard 63 + id={card.id} 64 + url={card.url} 65 + cardContent={card.cardContent} 66 + note={card.note} 67 + collections={card.collections} 68 + authorHandle={props.handle} 69 + /> 70 + </Grid.Col> 71 + ))} 72 + </Grid> 73 + </InfiniteScroll> 79 74 </Container> 80 75 ); 81 76 }
+9 -5
src/webapp/features/cards/lib/mutations/useAddCardToLibrary.tsx
··· 12 12 13 13 const mutation = useMutation({ 14 14 mutationFn: ({ 15 - cardId, 15 + url, 16 + note, 16 17 collectionIds, 17 18 }: { 18 - cardId: string; 19 + url: string; 20 + note?: string; 19 21 collectionIds: string[]; 20 22 }) => { 21 - return apiClient.addCardToLibrary({ cardId, collectionIds }); 23 + return apiClient.addUrlToLibrary({ url, note, collectionIds }); 22 24 }, 23 25 24 - onSuccess: (_data, variables) => { 25 - queryClient.invalidateQueries({ queryKey: ['card', variables.cardId] }); 26 + onSuccess: (data, variables) => { 27 + queryClient.invalidateQueries({ queryKey: ['card', data.urlCardId] }); 28 + queryClient.invalidateQueries({ queryKey: ['card', data.noteCardId] }); 29 + queryClient.invalidateQueries({ queryKey: ['card', variables.url] }); 26 30 queryClient.invalidateQueries({ queryKey: ['my cards'] }); 27 31 queryClient.invalidateQueries({ queryKey: ['home'] }); 28 32 queryClient.invalidateQueries({ queryKey: ['collections'] });
+1 -1
src/webapp/features/cards/lib/queries/useGetCard.tsx
··· 6 6 id: string; 7 7 } 8 8 9 - export default function useCard(props: Props) { 9 + export default function useGetCard(props: Props) { 10 10 const apiClient = new ApiClient( 11 11 process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 12 12 createClientTokenManager(),
+21
src/webapp/features/cards/lib/queries/useGetCardFromMyLibrary.tsx
··· 1 + import { ApiClient } from '@/api-client/ApiClient'; 2 + import { createClientTokenManager } from '@/services/auth'; 3 + import { useSuspenseQuery } from '@tanstack/react-query'; 4 + 5 + interface Props { 6 + url: string; 7 + } 8 + 9 + export default function useGetCardFromMyLibrary(props: Props) { 10 + const apiClient = new ApiClient( 11 + process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000', 12 + createClientTokenManager(), 13 + ); 14 + 15 + const cardStatus = useSuspenseQuery({ 16 + queryKey: ['card from my library', props.url], 17 + queryFn: () => apiClient.getUrlStatusForMyLibrary({ url: props.url }), 18 + }); 19 + 20 + return cardStatus; 21 + }
-279
src/webapp/features/collections/components/addToCollectionModal/AddToCollectionModal.tsx
··· 1 - import { UrlCardView } from '@/api-client/types'; 2 - import useCollectionSearch from '../../lib/queries/useCollectionSearch'; 3 - import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; 4 - import { 5 - Group, 6 - Modal, 7 - Stack, 8 - Text, 9 - TextInput, 10 - CloseButton, 11 - Tabs, 12 - ScrollArea, 13 - Button, 14 - Loader, 15 - Alert, 16 - } from '@mantine/core'; 17 - import { useDebouncedValue } from '@mantine/hooks'; 18 - import { Fragment, useState } from 'react'; 19 - import { IoSearch } from 'react-icons/io5'; 20 - import { BiPlus } from 'react-icons/bi'; 21 - import useMyCollections from '../../lib/queries/useMyCollections'; 22 - import useCard from '@/features/cards/lib/queries/useGetCard'; 23 - import useAddCardToCollection from '@/features/collections/lib/mutations/useAddCardToCollection'; 24 - import { notifications } from '@mantine/notifications'; 25 - import CollectionSelectorError from '../collectionSelector/Error.CollectionSelector'; 26 - import CardToBeAddedPreview from './CardToBeAddedPreview'; 27 - import CollectionSelectorItemList from '../collectionSelectorItemList/CollectionSelectorItemList'; 28 - import CreateCollectionDrawer from '../createCollectionDrawer/CreateCollectionDrawer'; 29 - 30 - interface Props { 31 - isOpen: boolean; 32 - onClose: () => void; 33 - cardContent: UrlCardView['cardContent']; 34 - cardId: string; 35 - } 36 - 37 - export default function AddToCollectionModal(props: Props) { 38 - const [isDrawerOpen, setIsDrawerOpen] = useState(false); 39 - 40 - const [search, setSearch] = useState<string>(''); 41 - const [debouncedSearch] = useDebouncedValue(search, 200); 42 - const searchedCollections = useCollectionSearch({ query: debouncedSearch }); 43 - 44 - const addCardToCollection = useAddCardToCollection(); 45 - const card = useCard({ id: props.cardId }); 46 - const { data, error } = useMyCollections(); 47 - const [selectedCollections, setSelectedCollections] = useState< 48 - SelectableCollectionItem[] 49 - >([]); 50 - 51 - const handleCollectionChange = ( 52 - checked: boolean, 53 - item: SelectableCollectionItem, 54 - ) => { 55 - if (checked) { 56 - if (!selectedCollections.some((col) => col.id === item.id)) { 57 - setSelectedCollections([...selectedCollections, item]); 58 - } 59 - } else { 60 - setSelectedCollections( 61 - selectedCollections.filter((col) => col.id !== item.id), 62 - ); 63 - } 64 - }; 65 - 66 - const allCollections = 67 - data?.pages.flatMap((page) => page.collections ?? []) ?? []; 68 - 69 - const collectionsWithCard = allCollections.filter((c) => 70 - card.data?.collections.some((col) => col.id === c.id), 71 - ); 72 - 73 - const collectionsWithoutCard = allCollections.filter( 74 - (c) => !collectionsWithCard.some((col) => col.id === c.id), 75 - ); 76 - 77 - const hasCollections = allCollections.length > 0; 78 - const hasSelectedCollections = selectedCollections.length > 0; 79 - 80 - const handleAddCardToCollection = (e: React.FormEvent) => { 81 - e.preventDefault(); 82 - 83 - addCardToCollection.mutate( 84 - { 85 - cardId: props.cardId, 86 - collectionIds: selectedCollections.map((c) => c.id), 87 - }, 88 - { 89 - onSuccess: () => { 90 - setSelectedCollections([]); 91 - props.onClose(); 92 - }, 93 - onError: () => { 94 - notifications.show({ 95 - message: 'Could not add card.', 96 - }); 97 - }, 98 - onSettled: () => { 99 - setSelectedCollections([]); 100 - props.onClose(); 101 - }, 102 - }, 103 - ); 104 - }; 105 - 106 - if (error) { 107 - return <CollectionSelectorError />; 108 - } 109 - 110 - return ( 111 - <Modal 112 - opened={props.isOpen} 113 - onClose={props.onClose} 114 - title="Add to Collections" 115 - overlayProps={DEFAULT_OVERLAY_PROPS} 116 - centered 117 - > 118 - <Stack gap={'xl'}> 119 - <CardToBeAddedPreview 120 - cardContent={props.cardContent} 121 - collectionsWithCard={collectionsWithCard} 122 - /> 123 - 124 - <Stack gap={'md'}> 125 - <TextInput 126 - placeholder="Search for collections" 127 - value={search} 128 - onChange={(e) => { 129 - setSearch(e.currentTarget.value); 130 - }} 131 - size="md" 132 - variant="filled" 133 - id="search" 134 - leftSection={<IoSearch size={22} />} 135 - rightSection={ 136 - <CloseButton 137 - aria-label="Clear input" 138 - onClick={() => setSearch('')} 139 - style={{ display: search ? undefined : 'none' }} 140 - /> 141 - } 142 - /> 143 - <Stack gap={'xl'}> 144 - <Tabs defaultValue={'collections'}> 145 - <Tabs.List grow> 146 - <Tabs.Tab value="collections">Collections</Tabs.Tab> 147 - <Tabs.Tab value="selected"> 148 - Selected ({selectedCollections.length}) 149 - </Tabs.Tab> 150 - </Tabs.List> 151 - 152 - <Tabs.Panel value="collections" my="xs" w="100%"> 153 - <ScrollArea.Autosize mah={200} type="auto"> 154 - <Stack gap="xs"> 155 - {search ? ( 156 - <Fragment> 157 - <Button 158 - variant="light" 159 - size="md" 160 - color="grape" 161 - radius="lg" 162 - leftSection={<BiPlus size={22} />} 163 - onClick={() => setIsDrawerOpen(true)} 164 - > 165 - Create new collection "{search}" 166 - </Button> 167 - 168 - {searchedCollections.isPending && ( 169 - <Stack align="center"> 170 - <Text fw={500} c="gray"> 171 - Searching collections... 172 - </Text> 173 - <Loader color="gray" /> 174 - </Stack> 175 - )} 176 - 177 - {searchedCollections.data && 178 - (searchedCollections.data.collections.length === 0 ? ( 179 - <Alert 180 - color="gray" 181 - title={`No results found for "${search}"`} 182 - /> 183 - ) : ( 184 - <CollectionSelectorItemList 185 - collections={searchedCollections.data.collections} 186 - collectionsWithCard={collectionsWithCard} 187 - selectedCollections={selectedCollections} 188 - onChange={handleCollectionChange} 189 - /> 190 - ))} 191 - </Fragment> 192 - ) : hasCollections ? ( 193 - <CollectionSelectorItemList 194 - collections={collectionsWithoutCard} 195 - selectedCollections={selectedCollections} 196 - onChange={handleCollectionChange} 197 - /> 198 - ) : ( 199 - <Stack align="center" gap="xs"> 200 - <Text fz="lg" fw={600} c="gray"> 201 - No collections 202 - </Text> 203 - <Button 204 - onClick={() => setIsDrawerOpen(true)} 205 - variant="light" 206 - color="gray" 207 - rightSection={<BiPlus size={22} />} 208 - > 209 - Create a collection 210 - </Button> 211 - </Stack> 212 - )} 213 - </Stack> 214 - </ScrollArea.Autosize> 215 - </Tabs.Panel> 216 - 217 - <Tabs.Panel value="selected" my="xs"> 218 - <ScrollArea.Autosize mah={200} type="auto"> 219 - <Stack gap="xs"> 220 - {hasSelectedCollections ? ( 221 - <CollectionSelectorItemList 222 - collections={selectedCollections} 223 - selectedCollections={selectedCollections} 224 - onChange={handleCollectionChange} 225 - /> 226 - ) : ( 227 - <Alert color="gray" title="No collections selected" /> 228 - )} 229 - </Stack> 230 - </ScrollArea.Autosize> 231 - </Tabs.Panel> 232 - </Tabs> 233 - 234 - <Group justify="space-between" gap="xs" grow> 235 - <Button 236 - variant="light" 237 - color="gray" 238 - size="md" 239 - onClick={() => { 240 - setSelectedCollections([]); 241 - props.onClose(); 242 - }} 243 - > 244 - Cancel 245 - </Button> 246 - {hasSelectedCollections && ( 247 - <Button 248 - variant="light" 249 - color="grape" 250 - size="md" 251 - onClick={() => setSelectedCollections([])} 252 - > 253 - Clear 254 - </Button> 255 - )} 256 - <Button 257 - size="md" 258 - onClick={handleAddCardToCollection} 259 - disabled={selectedCollections.length === 0} 260 - loading={addCardToCollection.isPending} 261 - > 262 - Save 263 - </Button> 264 - </Group> 265 - </Stack> 266 - </Stack> 267 - </Stack> 268 - <CreateCollectionDrawer 269 - key={search} 270 - isOpen={isDrawerOpen} 271 - onClose={() => setIsDrawerOpen(false)} 272 - initialName={search} 273 - onCreate={(newCollection) => { 274 - setSelectedCollections([...selectedCollections, newCollection]); 275 - }} 276 - /> 277 - </Modal> 278 - ); 279 - }
-93
src/webapp/features/collections/components/addToCollectionModal/CardToBeAddedPreview.tsx
··· 1 - import { 2 - AspectRatio, 3 - Group, 4 - Stack, 5 - Image, 6 - Text, 7 - Card, 8 - Menu, 9 - Button, 10 - ScrollArea, 11 - Anchor, 12 - } from '@mantine/core'; 13 - import { GetCollectionsResponse, UrlCardView } from '@/api-client/types'; 14 - import Link from 'next/link'; 15 - import { getDomain } from '@/lib/utils/link'; 16 - import { BiSolidChevronDownCircle } from 'react-icons/bi'; 17 - 18 - interface Props { 19 - cardContent: UrlCardView['cardContent']; 20 - collectionsWithCard: GetCollectionsResponse['collections']; 21 - } 22 - 23 - export default function CardToBeAddedPreview(props: Props) { 24 - const domain = getDomain(props.cardContent.url); 25 - 26 - return ( 27 - <Stack gap={'md'}> 28 - <Card withBorder p={'xs'} radius={'lg'}> 29 - <Stack> 30 - <Group gap={'sm'}> 31 - {props.cardContent.thumbnailUrl && ( 32 - <AspectRatio ratio={1 / 1} flex={0.1}> 33 - <Image 34 - src={props.cardContent.thumbnailUrl} 35 - alt={`${props.cardContent.url} social preview image`} 36 - radius={'md'} 37 - w={50} 38 - h={50} 39 - /> 40 - </AspectRatio> 41 - )} 42 - <Stack gap={0} flex={0.9}> 43 - <Anchor 44 - component={Link} 45 - href={props.cardContent.url} 46 - target="_blank" 47 - c={'gray'} 48 - lineClamp={1} 49 - > 50 - {domain} 51 - </Anchor> 52 - {props.cardContent.title && ( 53 - <Text fw={500} lineClamp={1}> 54 - {props.cardContent.title} 55 - </Text> 56 - )} 57 - </Stack> 58 - </Group> 59 - {props.collectionsWithCard.length > 0 && ( 60 - <Menu shadow="sm"> 61 - <Menu.Target> 62 - <Button 63 - variant="light" 64 - color="grape" 65 - rightSection={<BiSolidChevronDownCircle />} 66 - > 67 - Already in {props.collectionsWithCard.length} collection 68 - {props.collectionsWithCard.length !== 1 && 's'} 69 - </Button> 70 - </Menu.Target> 71 - <Menu.Dropdown maw={380}> 72 - <ScrollArea.Autosize mah={150} type="auto"> 73 - {props.collectionsWithCard.map((c) => ( 74 - <Menu.Item 75 - key={c.id} 76 - component={Link} 77 - href={`/profile/${c.createdBy.handle}/collections/${c.id}`} 78 - target="_blank" 79 - c="blue" 80 - fw={600} 81 - > 82 - {c.name} 83 - </Menu.Item> 84 - ))} 85 - </ScrollArea.Autosize> 86 - </Menu.Dropdown> 87 - </Menu> 88 - )} 89 - </Stack> 90 - </Card> 91 - </Stack> 92 - ); 93 - }
+12 -18
src/webapp/features/collections/containers/collectionsContainer/CollectionsContainer.tsx
··· 1 1 'use client'; 2 2 3 - import { Button, Container, Stack, SimpleGrid, Center } from '@mantine/core'; 3 + import { Button, Container, Stack, SimpleGrid } from '@mantine/core'; 4 4 import useCollections from '../../lib/queries/useCollections'; 5 5 import CollectionCard from '../../components/collectionCard/CollectionCard'; 6 6 import CreateCollectionDrawer from '../../components/createCollectionDrawer/CreateCollectionDrawer'; 7 7 import { useState } from 'react'; 8 8 import ProfileEmptyTab from '@/features/profile/components/profileEmptyTab/ProfileEmptyTab'; 9 9 import { BiCollection } from 'react-icons/bi'; 10 + import InfiniteLoadTrigger from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; 10 11 11 12 interface Props { 12 13 handle: string; ··· 32 33 return ( 33 34 <Container p="xs" size="xl"> 34 35 <Stack> 35 - <Stack> 36 + <InfiniteLoadTrigger 37 + dataLength={collections.length} 38 + hasMore={!!hasNextPage} 39 + isInitialLoading={false} // or you can manage initial loading state if needed 40 + isLoading={isFetchingNextPage} 41 + loadMore={fetchNextPage} 42 + manualLoadButton={false} // automatic infinite scroll; set true if you want manual button 43 + loader={<div>Loading...</div>} // replace with your loader component if you want 44 + > 36 45 <SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="md"> 37 46 {collections.map((collection) => ( 38 47 <CollectionCard key={collection.id} collection={collection} /> 39 48 ))} 40 49 </SimpleGrid> 41 - 42 - {hasNextPage && ( 43 - <Center> 44 - <Button 45 - onClick={() => fetchNextPage()} 46 - disabled={isFetchingNextPage} 47 - loading={isFetchingNextPage} 48 - variant="light" 49 - color="gray" 50 - mt="md" 51 - > 52 - Load More 53 - </Button> 54 - </Center> 55 - )} 56 - </Stack> 50 + </InfiniteLoadTrigger> 57 51 </Stack> 58 52 59 53 <CreateCollectionDrawer
+21 -19
src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx
··· 2 2 3 3 import useMyFeed from '@/features/feeds/lib/queries/useMyFeed'; 4 4 import FeedItem from '@/features/feeds/components/feedItem/FeedItem'; 5 - import { Button, Stack, Title, Text, Center, Container } from '@mantine/core'; 5 + import { Stack, Title, Text, Center, Container, Loader } from '@mantine/core'; 6 6 import MyFeedContainerSkeleton from './Skeleton.MyFeedContainer'; 7 7 import MyFeedContainerError from './Error.MyFeedContainer'; 8 + import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; 8 9 9 10 export default function MyFeedContainer() { 10 11 const { ··· 39 40 </Text> 40 41 </Center> 41 42 ) : ( 42 - <Stack gap={'xl'} mx={'auto'} maw={600}> 43 - <Stack gap={60}> 44 - {allActivities.map((item, i) => ( 45 - <FeedItem key={item.id} item={item} /> 46 - ))} 47 - </Stack> 48 - 49 - {hasNextPage && ( 43 + <InfiniteScroll 44 + dataLength={allActivities.length} 45 + hasMore={!!hasNextPage} 46 + isInitialLoading={isPending} 47 + isLoading={isFetchingNextPage} 48 + loadMore={fetchNextPage} 49 + manualLoadButton={false} 50 + loader={ 50 51 <Center> 51 - <Button 52 - onClick={() => fetchNextPage()} 53 - loading={isFetchingNextPage} 54 - variant="light" 55 - color="gray" 56 - > 57 - Load More 58 - </Button> 52 + <Loader /> 59 53 </Center> 60 - )} 61 - </Stack> 54 + } 55 + > 56 + <Stack gap={'xl'} mx={'auto'} maw={600}> 57 + <Stack gap={60}> 58 + {allActivities.map((item) => ( 59 + <FeedItem key={item.id} item={item} /> 60 + ))} 61 + </Stack> 62 + </Stack> 63 + </InfiniteScroll> 62 64 )} 63 65 </Stack> 64 66 </Container>
+17 -11
src/webapp/hooks/useUrlMetadata.ts
··· 1 1 import { useState, useEffect, useCallback } from 'react'; 2 2 import { ApiClient } from '@/api-client/ApiClient'; 3 3 import type { UrlMetadata } from '@/components/UrlMetadataDisplay'; 4 - import type { UrlCardView } from '@/api-client/types'; 4 + import type { 5 + GetUrlStatusForMyLibraryResponse, 6 + UrlCardView, 7 + } from '@/api-client/types'; 5 8 6 9 interface UseUrlMetadataProps { 7 10 apiClient: ApiClient; ··· 15 18 autoFetch = true, 16 19 }: UseUrlMetadataProps) { 17 20 const [metadata, setMetadata] = useState<UrlMetadata | null>(null); 18 - const [existingCard, setExistingCard] = useState<UrlCardView | null>(null); 21 + const [existingCardCollections, setExistingCardCollections] = useState< 22 + GetUrlStatusForMyLibraryResponse['collections'] | null 23 + >(null); 19 24 const [loading, setLoading] = useState(false); 20 25 const [error, setError] = useState<string | null>(null); 21 26 ··· 33 38 34 39 setLoading(true); 35 40 setError(null); 36 - setExistingCard(null); 41 + setExistingCardCollections(null); 37 42 38 43 try { 39 44 const response = await apiClient.getUrlMetadata(targetUrl); 40 45 setMetadata(response.metadata); 41 46 42 - // If there's an existing card, fetch its details including collections 43 - if (response.existingCardId) { 47 + const existingCard = await apiClient.getUrlStatusForMyLibrary({ 48 + url: targetUrl, 49 + }); 50 + 51 + // If there's an existing card, fetch its collections 52 + if (existingCard.cardId) { 44 53 try { 45 - const cardResponse = await apiClient.getUrlCardView( 46 - response.existingCardId, 47 - ); 48 - setExistingCard(cardResponse); 54 + setExistingCardCollections(existingCard.collections); 49 55 } catch (cardErr: any) { 50 56 console.error('Failed to fetch existing card details:', cardErr); 51 57 // Don't set error here as the metadata fetch was successful ··· 55 61 console.error('Failed to fetch URL metadata:', err); 56 62 setError('Failed to load page information'); 57 63 setMetadata(null); 58 - setExistingCard(null); 64 + setExistingCardCollections(null); 59 65 } finally { 60 66 setLoading(false); 61 67 } ··· 78 84 79 85 return { 80 86 metadata, 81 - existingCard, 87 + existingCardCollections, 82 88 loading, 83 89 error, 84 90 fetchMetadata,
+1
src/webapp/next-env.d.ts
··· 1 1 /// <reference types="next" /> 2 2 /// <reference types="next/image-types/global" /> 3 + /// <reference path="./.next/types/routes.d.ts" /> 3 4 4 5 // NOTE: This file should not be edited 5 6 // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.