A social knowledge tool for researchers built on ATProto
45
fork

Configure Feed

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

refactor: update methods for adding url and checking existing collections a card is in

Use getUrlStatusForMyLibrary and addUrlToLibrary

+62 -408
+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
+20 -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'; 19 22 import { getRecordKey } from '@/lib/utils/atproto'; 23 + import { Fragment } from 'react'; 20 24 21 25 interface Props { 22 26 cardId: string; 23 27 cardContent: UrlCardView['cardContent']; 24 - collectionsWithCard: GetCollectionsResponse['collections']; 28 + collectionsWithCard: GetUrlStatusForMyLibraryResponse['collections']; 25 29 isInLibrary: boolean; 26 30 } 27 31 ··· 78 82 In Library 79 83 </Button> 80 84 )} 81 - {props.collectionsWithCard.length > 0 && ( 85 + {props.collectionsWithCard && props.collectionsWithCard.length > 0 && ( 82 86 <Menu shadow="sm"> 83 87 <Menu.Target> 84 88 <Button ··· 93 97 <Menu.Dropdown maw={380}> 94 98 <ScrollArea.Autosize mah={150} type="auto"> 95 99 {props.collectionsWithCard.map((c) => ( 96 - <Menu.Item 97 - key={c.id} 98 - component={Link} 99 - href={`/profile/${c.createdBy.handle}/collections/${getRecordKey(c.uri!)}`} 100 - target="_blank" 101 - c="blue" 102 - fw={600} 103 - > 104 - {c.name} 105 - </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> 106 113 ))} 107 114 </ScrollArea.Autosize> 108 115 </Menu.Dropdown>
+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 - }