A social knowledge tool for researchers built on ATProto
at development 299 lines 10 kB view raw
1import { UrlCardView } from '@/api-client/types'; 2import useCollectionSearch from '@/features/collections/lib/queries/useCollectionSearch'; 3import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; 4import { 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'; 17import { useDebouncedValue } from '@mantine/hooks'; 18import { notifications } from '@mantine/notifications'; 19import { Fragment, useState } from 'react'; 20import { IoSearch } from 'react-icons/io5'; 21import { BiPlus } from 'react-icons/bi'; 22import CollectionSelectorError from '../../../collections/components/collectionSelector/Error.CollectionSelector'; 23import CollectionSelectorItemList from '../../../collections/components/collectionSelectorItemList/CollectionSelectorItemList'; 24import CreateCollectionDrawer from '../../../collections/components/createCollectionDrawer/CreateCollectionDrawer'; 25import CardToBeAddedPreview from './CardToBeAddedPreview'; 26import useAddCardToLibrary from '../../lib/mutations/useAddCardToLibrary'; 27import useGetCardFromMyLibrary from '../../lib/queries/useGetCardFromMyLibrary'; 28import useMyCollections from '../../../collections/lib/queries/useMyCollections'; 29 30interface Props { 31 isOpen: boolean; 32 onClose: () => void; 33 cardContent: UrlCardView['cardContent']; 34 cardId: string; 35} 36 37export default function AddCardToModal(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 addCardToLibrary = useAddCardToLibrary(); 45 46 const cardStaus = useGetCardFromMyLibrary({ url: props.cardContent.url }); 47 const { data, error } = useMyCollections(); 48 const [selectedCollections, setSelectedCollections] = useState< 49 SelectableCollectionItem[] 50 >([]); 51 52 const handleCollectionChange = ( 53 checked: boolean, 54 item: SelectableCollectionItem, 55 ) => { 56 if (checked) { 57 if (!selectedCollections.some((col) => col.id === item.id)) { 58 setSelectedCollections([...selectedCollections, item]); 59 } 60 } else { 61 setSelectedCollections( 62 selectedCollections.filter((col) => col.id !== item.id), 63 ); 64 } 65 }; 66 67 const allCollections = 68 data?.pages.flatMap((page) => page.collections ?? []) ?? []; 69 70 const collectionsWithCard = allCollections.filter((c) => 71 cardStaus.data.collections?.some((col) => col.id === c.id), 72 ); 73 74 const collectionsWithoutCard = allCollections.filter( 75 (c) => !collectionsWithCard.some((col) => col.id === c.id), 76 ); 77 78 const isInUserLibrary = collectionsWithCard.length > 0; 79 80 const hasCollections = allCollections.length > 0; 81 const hasSelectedCollections = selectedCollections.length > 0; 82 83 const handleAddCard = (e: React.FormEvent) => { 84 e.preventDefault(); 85 86 addCardToLibrary.mutate( 87 { 88 url: props.cardContent.url, 89 collectionIds: selectedCollections.map((c) => c.id), 90 }, 91 { 92 onSuccess: () => { 93 setSelectedCollections([]); 94 props.onClose(); 95 }, 96 onError: () => { 97 notifications.show({ 98 message: 'Could not add card.', 99 }); 100 }, 101 onSettled: () => { 102 setSelectedCollections([]); 103 props.onClose(); 104 }, 105 }, 106 ); 107 }; 108 109 if (error) { 110 return <CollectionSelectorError />; 111 } 112 113 return ( 114 <Modal 115 opened={props.isOpen} 116 onClose={props.onClose} 117 title="Add Card" 118 overlayProps={DEFAULT_OVERLAY_PROPS} 119 centered 120 > 121 <Stack gap={'xl'}> 122 <CardToBeAddedPreview 123 cardId={props.cardId} 124 cardContent={props.cardContent} 125 collectionsWithCard={collectionsWithCard} 126 isInLibrary={isInUserLibrary} 127 /> 128 129 <Stack gap={'md'}> 130 <TextInput 131 placeholder="Search for collections" 132 value={search} 133 onChange={(e) => { 134 setSearch(e.currentTarget.value); 135 }} 136 size="md" 137 variant="filled" 138 id="search" 139 leftSection={<IoSearch size={22} />} 140 rightSection={ 141 <CloseButton 142 aria-label="Clear input" 143 onClick={() => setSearch('')} 144 style={{ display: search ? undefined : 'none' }} 145 /> 146 } 147 /> 148 <Stack gap={'xl'}> 149 <Tabs defaultValue={'collections'}> 150 <Tabs.List grow> 151 <Tabs.Tab value="collections">Collections</Tabs.Tab> 152 <Tabs.Tab value="selected"> 153 Selected ({selectedCollections.length}) 154 </Tabs.Tab> 155 </Tabs.List> 156 157 <Tabs.Panel value="collections" my="xs" w="100%"> 158 <ScrollArea.Autosize mah={200} type="auto"> 159 <Stack gap="xs"> 160 {search ? ( 161 <Fragment> 162 <Button 163 variant="light" 164 size="md" 165 color="grape" 166 radius="lg" 167 leftSection={<BiPlus size={22} />} 168 onClick={() => setIsDrawerOpen(true)} 169 > 170 Create new collection "{search}" 171 </Button> 172 173 {searchedCollections.isPending && ( 174 <Stack align="center"> 175 <Text fw={500} c="gray"> 176 Searching collections... 177 </Text> 178 <Loader color="gray" /> 179 </Stack> 180 )} 181 182 {searchedCollections.data && 183 (searchedCollections.data.collections.length === 0 ? ( 184 <Alert 185 color="gray" 186 title={`No results found for "${search}"`} 187 /> 188 ) : ( 189 <CollectionSelectorItemList 190 collections={searchedCollections.data.collections} 191 collectionsWithCard={collectionsWithCard} 192 selectedCollections={selectedCollections} 193 onChange={handleCollectionChange} 194 /> 195 ))} 196 </Fragment> 197 ) : hasCollections ? ( 198 <Fragment> 199 <Button 200 variant="light" 201 size="md" 202 color="grape" 203 radius="lg" 204 leftSection={<BiPlus size={22} />} 205 onClick={() => setIsDrawerOpen(true)} 206 > 207 Create new collection 208 </Button> 209 <CollectionSelectorItemList 210 collections={collectionsWithoutCard} 211 selectedCollections={selectedCollections} 212 onChange={handleCollectionChange} 213 /> 214 </Fragment> 215 ) : ( 216 <Stack align="center" gap="xs"> 217 <Text fz="lg" fw={600} c="gray"> 218 No collections 219 </Text> 220 <Button 221 onClick={() => setIsDrawerOpen(true)} 222 variant="light" 223 color="gray" 224 rightSection={<BiPlus size={22} />} 225 > 226 Create a collection 227 </Button> 228 </Stack> 229 )} 230 </Stack> 231 </ScrollArea.Autosize> 232 </Tabs.Panel> 233 234 <Tabs.Panel value="selected" my="xs"> 235 <ScrollArea.Autosize mah={200} type="auto"> 236 <Stack gap="xs"> 237 {hasSelectedCollections ? ( 238 <CollectionSelectorItemList 239 collections={selectedCollections} 240 selectedCollections={selectedCollections} 241 onChange={handleCollectionChange} 242 /> 243 ) : ( 244 <Alert color="gray" title="No collections selected" /> 245 )} 246 </Stack> 247 </ScrollArea.Autosize> 248 </Tabs.Panel> 249 </Tabs> 250 251 <Group justify="space-between" gap="xs" grow> 252 <Button 253 variant="light" 254 color="gray" 255 size="md" 256 onClick={() => { 257 setSelectedCollections([]); 258 props.onClose(); 259 }} 260 > 261 Cancel 262 </Button> 263 {hasSelectedCollections && ( 264 <Button 265 variant="light" 266 color="grape" 267 size="md" 268 onClick={() => setSelectedCollections([])} 269 > 270 Clear 271 </Button> 272 )} 273 <Button 274 size="md" 275 onClick={handleAddCard} 276 // disabled when: 277 // user already has the card in a collection (and therefore in library) 278 // and no new collection is selected yet 279 disabled={isInUserLibrary && selectedCollections.length === 0} 280 loading={addCardToLibrary.isPending} 281 > 282 Add 283 </Button> 284 </Group> 285 </Stack> 286 </Stack> 287 </Stack> 288 <CreateCollectionDrawer 289 key={search} 290 isOpen={isDrawerOpen} 291 onClose={() => setIsDrawerOpen(false)} 292 initialName={search} 293 onCreate={(newCollection) => { 294 setSelectedCollections([...selectedCollections, newCollection]); 295 }} 296 /> 297 </Modal> 298 ); 299}