A social knowledge tool for researchers built on ATProto
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}