tangled
alpha
login
or
join now
cosmik.network
/
semble
43
fork
atom
A social knowledge tool for researchers built on ATProto
43
fork
atom
overview
issues
13
pulls
pipelines
feat: remove and add collections from url card
Pouria Delfanazari
4 months ago
0c5e8f69
3373b307
+334
-506
9 changed files
expand all
collapse all
unified
split
src
webapp
components
navigation
appLayout
AppLayout.tsx
features
cards
components
addCardDrawer
AddCardDrawer.tsx
addCardToModal
AddCardToModal.tsx
CardToBeAddedPreview.tsx
lib
mutations
useUpdateCardAssociations.tsx
collections
components
collectionSelector
CollectionSelector.tsx
composer
components
composerDrawer
ComposerDrawer.tsx
providers
mantine.tsx
styles
theme.tsx
-1
src/webapp/components/navigation/appLayout/AppLayout.tsx
···
2
2
import Navbar from '@/components/navigation/navbar/Navbar';
3
3
import ComposerDrawer from '@/features/composer/components/composerDrawer/ComposerDrawer';
4
4
import { useNavbarContext } from '@/providers/navbar';
5
5
-
import { useMediaQuery } from '@mantine/hooks';
6
5
import { usePathname } from 'next/navigation';
7
6
8
7
interface Props {
+31
-8
src/webapp/features/cards/components/addCardDrawer/AddCardDrawer.tsx
···
1
1
import {
2
2
Button,
3
3
+
Center,
3
4
Container,
4
5
Drawer,
5
6
Group,
···
139
140
</Tooltip>
140
141
</Stack>
141
142
</Group>
142
142
-
<Suspense fallback={<CollectionSelectorSkeleton />}>
143
143
-
<CollectionSelector
144
144
-
isOpen={collectionSelectorOpened}
145
145
-
onClose={toggleCollectionSelector}
146
146
-
selectedCollections={selectedCollections}
147
147
-
onSelectedCollectionsChange={setSelectedCollections}
148
148
-
/>
149
149
-
</Suspense>
143
143
+
144
144
+
<Drawer
145
145
+
opened={collectionSelectorOpened}
146
146
+
onClose={toggleCollectionSelector}
147
147
+
withCloseButton={false}
148
148
+
position="bottom"
149
149
+
size={'40rem'}
150
150
+
overlayProps={DEFAULT_OVERLAY_PROPS}
151
151
+
>
152
152
+
<Drawer.Header>
153
153
+
<Drawer.Title fz={'xl'} fw={600} mx={'auto'}>
154
154
+
Add to collections
155
155
+
</Drawer.Title>
156
156
+
</Drawer.Header>
157
157
+
<Container size={'xs'}>
158
158
+
<Suspense fallback={<CollectionSelectorSkeleton />}>
159
159
+
<CollectionSelector
160
160
+
isOpen={collectionSelectorOpened}
161
161
+
onCancel={() => {
162
162
+
setSelectedCollections([]);
163
163
+
toggleCollectionSelector();
164
164
+
}}
165
165
+
onClose={toggleCollectionSelector}
166
166
+
onSave={toggleCollectionSelector}
167
167
+
selectedCollections={selectedCollections}
168
168
+
onSelectedCollectionsChange={setSelectedCollections}
169
169
+
/>
170
170
+
</Suspense>
171
171
+
</Container>
172
172
+
</Drawer>
150
173
</Stack>
151
174
<Group justify="space-between" gap={'xs'} grow>
152
175
<Button
+64
-239
src/webapp/features/cards/components/addCardToModal/AddCardToModal.tsx
···
1
1
import type { UrlCard } from '@/api-client';
2
2
-
import useCollectionSearch from '@/features/collections/lib/queries/useCollectionSearch';
3
2
import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays';
4
4
-
import {
5
5
-
Group,
6
6
-
Modal,
7
7
-
Stack,
8
8
-
Text,
9
9
-
TextInput,
10
10
-
CloseButton,
11
11
-
Tabs,
12
12
-
ScrollArea,
13
13
-
Button,
14
14
-
Loader,
15
15
-
Alert,
16
16
-
} from '@mantine/core';
17
17
-
import { useDebouncedValue } from '@mantine/hooks';
3
3
+
import { Modal, Stack } from '@mantine/core';
18
4
import { notifications } from '@mantine/notifications';
19
19
-
import { Fragment, useState } from 'react';
20
20
-
import { IoSearch } from 'react-icons/io5';
21
21
-
import { FiPlus } from 'react-icons/fi';
5
5
+
import { Suspense, useState } from 'react';
22
6
import CollectionSelectorError from '../../../collections/components/collectionSelector/Error.CollectionSelector';
23
23
-
import CollectionSelectorItemList from '../../../collections/components/collectionSelectorItemList/CollectionSelectorItemList';
24
24
-
import CreateCollectionDrawer from '../../../collections/components/createCollectionDrawer/CreateCollectionDrawer';
25
7
import CardToBeAddedPreview from './CardToBeAddedPreview';
26
26
-
import useAddCardToLibrary from '../../lib/mutations/useAddCardToLibrary';
27
8
import useGetCardFromMyLibrary from '../../lib/queries/useGetCardFromMyLibrary';
28
9
import useMyCollections from '../../../collections/lib/queries/useMyCollections';
10
10
+
import CollectionSelector from '@/features/collections/components/collectionSelector/CollectionSelector';
11
11
+
import useUpdateCardAssociations from '../../lib/mutations/useUpdateCardAssociations';
12
12
+
import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector';
29
13
30
14
interface Props {
31
15
isOpen: boolean;
···
35
19
}
36
20
37
21
export default function AddCardToModal(props: Props) {
38
38
-
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
39
39
-
40
40
-
const [search, setSearch] = useState<string>('');
41
41
-
const [debouncedSearch] = useDebouncedValue(search, 200);
42
42
-
const searchedCollections = useCollectionSearch({ query: debouncedSearch });
43
43
-
44
44
-
const addCardToLibrary = useAddCardToLibrary();
45
45
-
46
46
-
const cardStaus = useGetCardFromMyLibrary({ url: props.cardContent.url });
22
22
+
const cardStatus = useGetCardFromMyLibrary({ url: props.cardContent.url });
47
23
const { data, error } = useMyCollections();
48
48
-
const [selectedCollections, setSelectedCollections] = useState<
49
49
-
SelectableCollectionItem[]
50
50
-
>([]);
51
51
-
52
52
-
const handleCollectionChange = (
53
53
-
checked: boolean,
54
54
-
item: SelectableCollectionItem,
55
55
-
) => {
56
56
-
if (checked) {
57
57
-
if (!selectedCollections.some((col) => col.id === item.id)) {
58
58
-
setSelectedCollections([...selectedCollections, item]);
59
59
-
}
60
60
-
} else {
61
61
-
setSelectedCollections(
62
62
-
selectedCollections.filter((col) => col.id !== item.id),
63
63
-
);
64
64
-
}
65
65
-
};
66
24
67
25
const allCollections =
68
26
data?.pages.flatMap((page) => page.collections ?? []) ?? [];
69
27
70
28
const collectionsWithCard = allCollections.filter((c) =>
71
71
-
cardStaus.data.collections?.some((col) => col.id === c.id),
72
72
-
);
73
73
-
74
74
-
const collectionsWithoutCard = allCollections.filter(
75
75
-
(c) => !collectionsWithCard.some((col) => col.id === c.id),
29
29
+
cardStatus.data.collections?.some((col) => col.id === c.id),
76
30
);
77
31
78
78
-
const isInUserLibrary = collectionsWithCard.length > 0;
32
32
+
const [selectedCollections, setSelectedCollections] =
33
33
+
useState<SelectableCollectionItem[]>(collectionsWithCard);
79
34
80
80
-
const hasCollections = allCollections.length > 0;
81
81
-
const hasSelectedCollections = selectedCollections.length > 0;
35
35
+
const updateCardAssociations = useUpdateCardAssociations();
82
36
83
83
-
const handleAddCard = (e: React.FormEvent) => {
37
37
+
const handleUpdateCard = (e: React.FormEvent) => {
84
38
e.preventDefault();
85
39
86
86
-
addCardToLibrary.mutate(
40
40
+
const addedCollections = selectedCollections.filter(
41
41
+
(c) => !collectionsWithCard.some((original) => original.id === c.id),
42
42
+
);
43
43
+
44
44
+
const removedCollections = collectionsWithCard.filter(
45
45
+
(c) => !selectedCollections.some((selected) => selected.id === c.id),
46
46
+
);
47
47
+
48
48
+
if (addedCollections.length === 0 && removedCollections.length === 0) {
49
49
+
props.onClose();
50
50
+
return;
51
51
+
}
52
52
+
53
53
+
updateCardAssociations.mutate(
87
54
{
88
88
-
url: props.cardContent.url,
89
89
-
collectionIds: selectedCollections.map((c) => c.id),
55
55
+
cardId: props.cardId,
56
56
+
addToCollectionIds: addedCollections.map((c) => c.id),
57
57
+
removeFromCollectionIds: removedCollections.map((c) => c.id),
90
58
},
91
59
{
92
60
onSuccess: () => {
93
93
-
setSelectedCollections([]);
94
94
-
props.onClose();
61
61
+
const addedCount = addedCollections.length;
62
62
+
const removedCount = removedCollections.length;
63
63
+
64
64
+
let message = '';
65
65
+
66
66
+
if (addedCount > 0 && removedCount > 0) {
67
67
+
message = `Added to ${addedCount} collection${addedCount > 1 ? 's' : ''} and removed from ${removedCount} collection${removedCount > 1 ? 's' : ''}.`;
68
68
+
} else if (addedCount > 0) {
69
69
+
message = `Added to ${addedCount} collection${addedCount > 1 ? 's' : ''}.`;
70
70
+
} else if (removedCount > 0) {
71
71
+
message = `Removed from ${removedCount} collection${removedCount > 1 ? 's' : ''}.`;
72
72
+
}
73
73
+
74
74
+
notifications.show({
75
75
+
message,
76
76
+
});
95
77
},
78
78
+
96
79
onError: () => {
97
80
notifications.show({
98
98
-
message: 'Could not add card.',
81
81
+
message: 'Could not update card.',
99
82
});
100
83
},
101
84
onSettled: () => {
102
102
-
setSelectedCollections([]);
103
85
props.onClose();
104
86
},
105
87
},
···
113
95
return (
114
96
<Modal
115
97
opened={props.isOpen}
116
116
-
onClose={props.onClose}
117
117
-
title="Add Card"
98
98
+
onClose={() => {
99
99
+
props.onClose();
100
100
+
setSelectedCollections(collectionsWithCard);
101
101
+
}}
102
102
+
title="Add or Update Card"
118
103
overlayProps={DEFAULT_OVERLAY_PROPS}
119
104
centered
120
105
onClick={(e) => e.stopPropagation()}
121
106
>
122
122
-
<Stack gap={'xl'}>
123
123
-
<CardToBeAddedPreview
124
124
-
cardId={props.cardId}
125
125
-
cardContent={props.cardContent}
126
126
-
collectionsWithCard={collectionsWithCard}
127
127
-
isInLibrary={isInUserLibrary}
128
128
-
/>
107
107
+
<Stack justify="space-between">
108
108
+
<CardToBeAddedPreview cardContent={props.cardContent} />
129
109
130
130
-
<Stack gap={'md'}>
131
131
-
<TextInput
132
132
-
placeholder="Search for collections"
133
133
-
value={search}
134
134
-
onChange={(e) => {
135
135
-
setSearch(e.currentTarget.value);
110
110
+
<Suspense fallback={<CollectionSelectorSkeleton />}>
111
111
+
<CollectionSelector
112
112
+
isOpen={true}
113
113
+
onClose={props.onClose}
114
114
+
onCancel={() => {
115
115
+
props.onClose();
116
116
+
setSelectedCollections(collectionsWithCard);
136
117
}}
137
137
-
size="md"
138
138
-
variant="filled"
139
139
-
id="search"
140
140
-
leftSection={<IoSearch size={22} />}
141
141
-
rightSection={
142
142
-
<CloseButton
143
143
-
aria-label="Clear input"
144
144
-
onClick={() => setSearch('')}
145
145
-
style={{ display: search ? undefined : 'none' }}
146
146
-
/>
147
147
-
}
118
118
+
onSave={handleUpdateCard}
119
119
+
selectedCollections={selectedCollections}
120
120
+
onSelectedCollectionsChange={setSelectedCollections}
148
121
/>
149
149
-
<Stack gap={'xl'}>
150
150
-
<Tabs defaultValue={'collections'}>
151
151
-
<Tabs.List grow>
152
152
-
<Tabs.Tab value="collections">Collections</Tabs.Tab>
153
153
-
<Tabs.Tab value="selected">
154
154
-
Selected ({selectedCollections.length})
155
155
-
</Tabs.Tab>
156
156
-
</Tabs.List>
157
157
-
158
158
-
<Tabs.Panel value="collections" my="xs" w="100%">
159
159
-
<ScrollArea.Autosize mah={200} type="auto">
160
160
-
<Stack gap="xs">
161
161
-
{search ? (
162
162
-
<Fragment>
163
163
-
<Button
164
164
-
variant="light"
165
165
-
size="md"
166
166
-
color="grape"
167
167
-
radius="lg"
168
168
-
leftSection={<FiPlus size={22} />}
169
169
-
onClick={() => setIsDrawerOpen(true)}
170
170
-
>
171
171
-
Create new collection "{search}"
172
172
-
</Button>
173
173
-
174
174
-
{searchedCollections.isPending && (
175
175
-
<Stack align="center">
176
176
-
<Text fw={500} c="gray">
177
177
-
Searching collections...
178
178
-
</Text>
179
179
-
<Loader color="gray" />
180
180
-
</Stack>
181
181
-
)}
182
182
-
183
183
-
{searchedCollections.data &&
184
184
-
(searchedCollections.data.collections.length === 0 ? (
185
185
-
<Alert
186
186
-
color="gray"
187
187
-
title={`No results found for "${search}"`}
188
188
-
/>
189
189
-
) : (
190
190
-
<CollectionSelectorItemList
191
191
-
collections={searchedCollections.data.collections}
192
192
-
collectionsWithCard={collectionsWithCard}
193
193
-
selectedCollections={selectedCollections}
194
194
-
onChange={handleCollectionChange}
195
195
-
/>
196
196
-
))}
197
197
-
</Fragment>
198
198
-
) : hasCollections ? (
199
199
-
<Fragment>
200
200
-
<Button
201
201
-
variant="light"
202
202
-
size="md"
203
203
-
color="grape"
204
204
-
radius="lg"
205
205
-
leftSection={<FiPlus size={22} />}
206
206
-
onClick={() => setIsDrawerOpen(true)}
207
207
-
>
208
208
-
Create new collection
209
209
-
</Button>
210
210
-
<CollectionSelectorItemList
211
211
-
collections={collectionsWithoutCard}
212
212
-
selectedCollections={selectedCollections}
213
213
-
onChange={handleCollectionChange}
214
214
-
/>
215
215
-
</Fragment>
216
216
-
) : (
217
217
-
<Stack align="center" gap="xs">
218
218
-
<Text fz="lg" fw={600} c="gray">
219
219
-
No collections
220
220
-
</Text>
221
221
-
<Button
222
222
-
onClick={() => setIsDrawerOpen(true)}
223
223
-
variant="light"
224
224
-
color="gray"
225
225
-
rightSection={<FiPlus size={22} />}
226
226
-
>
227
227
-
Create a collection
228
228
-
</Button>
229
229
-
</Stack>
230
230
-
)}
231
231
-
</Stack>
232
232
-
</ScrollArea.Autosize>
233
233
-
</Tabs.Panel>
234
234
-
235
235
-
<Tabs.Panel value="selected" my="xs">
236
236
-
<ScrollArea.Autosize mah={200} type="auto">
237
237
-
<Stack gap="xs">
238
238
-
{hasSelectedCollections ? (
239
239
-
<CollectionSelectorItemList
240
240
-
collections={selectedCollections}
241
241
-
selectedCollections={selectedCollections}
242
242
-
onChange={handleCollectionChange}
243
243
-
/>
244
244
-
) : (
245
245
-
<Alert color="gray" title="No collections selected" />
246
246
-
)}
247
247
-
</Stack>
248
248
-
</ScrollArea.Autosize>
249
249
-
</Tabs.Panel>
250
250
-
</Tabs>
251
251
-
252
252
-
<Group justify="space-between" gap="xs" grow>
253
253
-
<Button
254
254
-
variant="light"
255
255
-
color="gray"
256
256
-
size="md"
257
257
-
onClick={() => {
258
258
-
setSelectedCollections([]);
259
259
-
props.onClose();
260
260
-
}}
261
261
-
>
262
262
-
Cancel
263
263
-
</Button>
264
264
-
{hasSelectedCollections && (
265
265
-
<Button
266
266
-
variant="light"
267
267
-
color="grape"
268
268
-
size="md"
269
269
-
onClick={() => setSelectedCollections([])}
270
270
-
>
271
271
-
Clear
272
272
-
</Button>
273
273
-
)}
274
274
-
<Button
275
275
-
size="md"
276
276
-
onClick={handleAddCard}
277
277
-
// disabled when:
278
278
-
// user already has the card in a collection (and therefore in library)
279
279
-
// and no new collection is selected yet
280
280
-
disabled={isInUserLibrary && selectedCollections.length === 0}
281
281
-
loading={addCardToLibrary.isPending}
282
282
-
>
283
283
-
Add
284
284
-
</Button>
285
285
-
</Group>
286
286
-
</Stack>
287
287
-
</Stack>
122
122
+
</Suspense>
288
123
</Stack>
289
289
-
<CreateCollectionDrawer
290
290
-
key={search}
291
291
-
isOpen={isDrawerOpen}
292
292
-
onClose={() => setIsDrawerOpen(false)}
293
293
-
initialName={search}
294
294
-
onCreate={(newCollection) => {
295
295
-
setSelectedCollections([...selectedCollections, newCollection]);
296
296
-
setSearch('');
297
297
-
}}
298
298
-
/>
299
124
</Modal>
300
125
);
301
126
}
+51
-101
src/webapp/features/cards/components/addCardToModal/CardToBeAddedPreview.tsx
···
5
5
Image,
6
6
Text,
7
7
Card,
8
8
-
Menu,
9
9
-
Button,
10
10
-
ScrollArea,
11
8
Anchor,
12
9
Tooltip,
13
10
} from '@mantine/core';
14
11
import Link from 'next/link';
15
15
-
import {
16
16
-
GetUrlStatusForMyLibraryResponse,
17
17
-
UrlCard,
18
18
-
Collection,
19
19
-
} from '@/api-client';
20
20
-
import { BiCollection } from 'react-icons/bi';
21
21
-
import { LuLibrary } from 'react-icons/lu';
12
12
+
import { MouseEvent } from 'react';
13
13
+
import { UrlCard } from '@/api-client';
22
14
import { getDomain } from '@/lib/utils/link';
23
23
-
import useMyProfile from '@/features/profile/lib/queries/useMyProfile';
24
24
-
import { getRecordKey } from '@/lib/utils/atproto';
25
25
-
import { Fragment } from 'react';
15
15
+
import { useRouter } from 'next/navigation';
26
16
27
17
interface Props {
28
28
-
cardId: string;
29
18
cardContent: UrlCard['cardContent'];
30
30
-
collectionsWithCard: GetUrlStatusForMyLibraryResponse['collections'];
31
31
-
isInLibrary: boolean;
32
19
}
33
20
34
21
export default function CardToBeAddedPreview(props: Props) {
35
22
const domain = getDomain(props.cardContent.url);
36
36
-
const { data: profile } = useMyProfile();
23
23
+
const router = useRouter();
37
24
38
38
-
return (
39
39
-
<Stack gap={'xs'}>
40
40
-
<Card withBorder p={'xs'} radius={'lg'}>
41
41
-
<Stack>
42
42
-
<Group gap={'sm'}>
43
43
-
{props.cardContent.thumbnailUrl && (
44
44
-
<AspectRatio ratio={1 / 1} flex={0.1}>
45
45
-
<Image
46
46
-
src={props.cardContent.thumbnailUrl}
47
47
-
alt={`${props.cardContent.url} social preview image`}
48
48
-
radius={'md'}
49
49
-
w={50}
50
50
-
h={50}
51
51
-
/>
52
52
-
</AspectRatio>
53
53
-
)}
54
54
-
<Stack gap={0} flex={0.9}>
55
55
-
<Tooltip label={props.cardContent.url}>
56
56
-
<Anchor
57
57
-
component={Link}
58
58
-
href={props.cardContent.url}
59
59
-
target="_blank"
60
60
-
c={'gray'}
61
61
-
lineClamp={1}
62
62
-
>
63
63
-
{domain}
64
64
-
</Anchor>
65
65
-
</Tooltip>
66
66
-
{props.cardContent.title && (
67
67
-
<Text fw={500} lineClamp={1}>
68
68
-
{props.cardContent.title}
69
69
-
</Text>
70
70
-
)}
71
71
-
</Stack>
72
72
-
</Group>
73
73
-
</Stack>
74
74
-
</Card>
25
25
+
const handleNavigateToSemblePage = (e: MouseEvent<HTMLElement>) => {
26
26
+
e.stopPropagation();
27
27
+
router.push(`/url?id=${props.cardContent.url}`);
28
28
+
};
75
29
76
76
-
<Group>
77
77
-
{props.isInLibrary && (
78
78
-
<Button
79
79
-
variant="light"
80
80
-
color="green"
81
81
-
component={Link}
82
82
-
href={`/profile/${profile.handle}/cards/${props.cardId}`}
83
83
-
target="_blank"
84
84
-
leftSection={<LuLibrary size={22} />}
85
85
-
>
86
86
-
In Library
87
87
-
</Button>
88
88
-
)}
89
89
-
{props.collectionsWithCard && props.collectionsWithCard.length > 0 && (
90
90
-
<Menu shadow="sm">
91
91
-
<Menu.Target>
92
92
-
<Button
93
93
-
variant="light"
94
94
-
color="grape"
95
95
-
leftSection={<BiCollection size={22} />}
30
30
+
return (
31
31
+
<Card
32
32
+
withBorder
33
33
+
component="article"
34
34
+
p={'xs'}
35
35
+
radius={'lg'}
36
36
+
style={{ cursor: 'pointer' }}
37
37
+
onClick={handleNavigateToSemblePage}
38
38
+
>
39
39
+
<Stack>
40
40
+
<Group gap={'sm'}>
41
41
+
{props.cardContent.thumbnailUrl && (
42
42
+
<AspectRatio ratio={1 / 1} flex={0.1}>
43
43
+
<Image
44
44
+
src={props.cardContent.thumbnailUrl}
45
45
+
alt={`${props.cardContent.url} social preview image`}
46
46
+
radius={'md'}
47
47
+
w={50}
48
48
+
h={50}
49
49
+
/>
50
50
+
</AspectRatio>
51
51
+
)}
52
52
+
<Stack gap={0} flex={0.9}>
53
53
+
<Tooltip label={props.cardContent.url}>
54
54
+
<Anchor
55
55
+
component={Link}
56
56
+
href={props.cardContent.url}
57
57
+
target="_blank"
58
58
+
c={'gray'}
59
59
+
lineClamp={1}
60
60
+
onClick={(e) => e.stopPropagation()}
96
61
>
97
97
-
In {props.collectionsWithCard.length} Collection
98
98
-
{props.collectionsWithCard.length !== 1 && 's'}
99
99
-
</Button>
100
100
-
</Menu.Target>
101
101
-
<Menu.Dropdown maw={380}>
102
102
-
<ScrollArea.Autosize mah={150} type="auto">
103
103
-
{props.collectionsWithCard.map((c: Collection) => (
104
104
-
<Fragment key={c.id}>
105
105
-
{c.uri && (
106
106
-
<Menu.Item
107
107
-
component={Link}
108
108
-
href={`/profile/${profile.handle}/collections/${getRecordKey(c.uri)}`}
109
109
-
target="_blank"
110
110
-
c="blue"
111
111
-
fw={600}
112
112
-
>
113
113
-
{c.name}
114
114
-
</Menu.Item>
115
115
-
)}
116
116
-
</Fragment>
117
117
-
))}
118
118
-
</ScrollArea.Autosize>
119
119
-
</Menu.Dropdown>
120
120
-
</Menu>
121
121
-
)}
122
122
-
</Group>
123
123
-
</Stack>
62
62
+
{domain}
63
63
+
</Anchor>
64
64
+
</Tooltip>
65
65
+
{props.cardContent.title && (
66
66
+
<Text fw={500} lineClamp={1}>
67
67
+
{props.cardContent.title}
68
68
+
</Text>
69
69
+
)}
70
70
+
</Stack>
71
71
+
</Group>
72
72
+
</Stack>
73
73
+
</Card>
124
74
);
125
75
}
+44
src/webapp/features/cards/lib/mutations/useUpdateCardAssociations.tsx
···
1
1
+
import { createSembleClient } from '@/services/apiClient';
2
2
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
3
3
+
4
4
+
export default function useUpdateCardAssociations() {
5
5
+
const client = createSembleClient();
6
6
+
7
7
+
const queryClient = useQueryClient();
8
8
+
9
9
+
const mutation = useMutation({
10
10
+
mutationFn: (updatedCard: {
11
11
+
cardId: string;
12
12
+
note?: string;
13
13
+
addToCollectionIds?: string[];
14
14
+
removeFromCollectionIds?: string[];
15
15
+
}) => {
16
16
+
return client.updateUrlCardAssociations({
17
17
+
cardId: updatedCard.cardId,
18
18
+
note: updatedCard.note,
19
19
+
addToCollections: updatedCard.addToCollectionIds,
20
20
+
removeFromCollections: updatedCard.removeFromCollectionIds,
21
21
+
});
22
22
+
},
23
23
+
24
24
+
onSuccess: (_data, variables) => {
25
25
+
queryClient.invalidateQueries({ queryKey: ['my cards'] });
26
26
+
queryClient.invalidateQueries({ queryKey: ['home'] });
27
27
+
queryClient.invalidateQueries({ queryKey: ['collections'] });
28
28
+
queryClient.invalidateQueries({
29
29
+
queryKey: ['card from my library'],
30
30
+
});
31
31
+
32
32
+
// invalidate each collection query individually
33
33
+
variables.addToCollectionIds?.forEach((id) => {
34
34
+
queryClient.invalidateQueries({ queryKey: ['collection', id] });
35
35
+
});
36
36
+
37
37
+
variables.removeFromCollectionIds?.forEach((id) => {
38
38
+
queryClient.invalidateQueries({ queryKey: ['collection', id] });
39
39
+
});
40
40
+
},
41
41
+
});
42
42
+
43
43
+
return mutation;
44
44
+
}
+136
-155
src/webapp/features/collections/components/collectionSelector/CollectionSelector.tsx
···
3
3
import {
4
4
ScrollArea,
5
5
Stack,
6
6
-
Tabs,
7
6
TextInput,
8
7
Text,
9
8
Alert,
10
9
Loader,
11
10
CloseButton,
12
11
Button,
13
13
-
Drawer,
14
14
-
Container,
15
12
Group,
13
13
+
Divider,
16
14
} from '@mantine/core';
17
15
import { Fragment, useState } from 'react';
18
16
import { useDebouncedValue } from '@mantine/hooks';
···
23
21
import CollectionSelectorError from './Error.CollectionSelector';
24
22
import { FiPlus } from 'react-icons/fi';
25
23
import { IoSearch } from 'react-icons/io5';
26
26
-
import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays';
27
24
28
25
interface Props {
29
26
isOpen: boolean;
30
27
onClose: () => void;
28
28
+
onCancel: () => void;
29
29
+
onSave: (e: React.FormEvent) => void;
31
30
selectedCollections: SelectableCollectionItem[];
32
31
onSelectedCollectionsChange: (
33
32
collectionIds: SelectableCollectionItem[],
···
66
65
const hasCollections = allCollections.length > 0;
67
66
const hasSelectedCollections = props.selectedCollections.length > 0;
68
67
68
68
+
// filter out selected from all to avoid duplication
69
69
+
const unselectedCollections = allCollections.filter(
70
70
+
(c) => !props.selectedCollections.some((sel) => sel.id === c.id),
71
71
+
);
72
72
+
69
73
return (
70
74
<Fragment>
71
71
-
<Drawer
72
72
-
opened={props.isOpen}
73
73
-
onClose={props.onClose}
74
74
-
withCloseButton={false}
75
75
-
position="bottom"
76
76
-
size={'40rem'}
77
77
-
overlayProps={DEFAULT_OVERLAY_PROPS}
78
78
-
>
79
79
-
<Drawer.Header>
80
80
-
<Drawer.Title fz={'xl'} fw={600} mx={'auto'}>
81
81
-
Add to collections
82
82
-
</Drawer.Title>
83
83
-
</Drawer.Header>
84
84
-
<Container size={'xs'}>
85
85
-
<Stack gap={'xl'}>
86
86
-
<TextInput
87
87
-
placeholder="Search for collections"
88
88
-
value={search}
89
89
-
onChange={(e) => setSearch(e.currentTarget.value)}
90
90
-
size="md"
91
91
-
variant="filled"
92
92
-
id="search"
93
93
-
leftSection={<IoSearch size={22} />}
94
94
-
rightSection={
95
95
-
<CloseButton
96
96
-
aria-label="Clear input"
97
97
-
onClick={() => setSearch('')}
98
98
-
style={{ display: search ? undefined : 'none' }}
99
99
-
/>
100
100
-
}
101
101
-
/>
102
102
-
<Tabs defaultValue="collections">
103
103
-
<Tabs.List grow>
104
104
-
<Tabs.Tab value="collections">Collections</Tabs.Tab>
105
105
-
<Tabs.Tab value="selected">
106
106
-
Selected ({props.selectedCollections.length})
107
107
-
</Tabs.Tab>
108
108
-
</Tabs.List>
75
75
+
<Stack gap="xl">
76
76
+
<Stack>
77
77
+
<TextInput
78
78
+
placeholder="Search for collections"
79
79
+
value={search}
80
80
+
onChange={(e) => setSearch(e.currentTarget.value)}
81
81
+
size="md"
82
82
+
variant="filled"
83
83
+
id="search"
84
84
+
leftSection={<IoSearch size={22} />}
85
85
+
rightSection={
86
86
+
<CloseButton
87
87
+
aria-label="Clear input"
88
88
+
onClick={() => setSearch('')}
89
89
+
style={{ display: search ? undefined : 'none' }}
90
90
+
/>
91
91
+
}
92
92
+
/>
109
93
110
110
-
{/* Collections Panel */}
111
111
-
<Tabs.Panel value="collections" my="xs" w="100%">
112
112
-
<ScrollArea h={340} type="auto">
113
113
-
<Stack gap="xs">
114
114
-
{search ? (
115
115
-
<Fragment>
116
116
-
<Button
117
117
-
variant="light"
118
118
-
size="md"
119
119
-
color="grape"
120
120
-
radius="lg"
121
121
-
leftSection={<FiPlus size={22} />}
122
122
-
onClick={() => setIsDrawerOpen(true)}
123
123
-
>
124
124
-
Create new collection "{search}"
125
125
-
</Button>
94
94
+
<ScrollArea h={300} type="auto">
95
95
+
<Stack gap="xs">
96
96
+
{search ? (
97
97
+
<>
98
98
+
<Button
99
99
+
variant="light"
100
100
+
size="md"
101
101
+
color="grape"
102
102
+
radius="lg"
103
103
+
leftSection={<FiPlus size={22} />}
104
104
+
onClick={() => setIsDrawerOpen(true)}
105
105
+
>
106
106
+
Create new collection "{search}"
107
107
+
</Button>
126
108
127
127
-
{searchedCollections.isPending && (
128
128
-
<Stack align="center">
129
129
-
<Text fw={500} c="gray">
130
130
-
Searching collections...
131
131
-
</Text>
132
132
-
<Loader color="gray" />
133
133
-
</Stack>
134
134
-
)}
109
109
+
{searchedCollections.isPending && (
110
110
+
<Stack align="center">
111
111
+
<Text fw={500} c="gray">
112
112
+
Searching collections...
113
113
+
</Text>
114
114
+
<Loader color="gray" />
115
115
+
</Stack>
116
116
+
)}
135
117
136
136
-
{searchedCollections.data &&
137
137
-
(searchedCollections.data.collections.length === 0 ? (
138
138
-
<Alert
139
139
-
color="gray"
140
140
-
title={`No results found for "${search}"`}
141
141
-
/>
142
142
-
) : (
143
143
-
<CollectionSelectorItemList
144
144
-
collections={searchedCollections.data.collections}
145
145
-
selectedCollections={props.selectedCollections}
146
146
-
onChange={handleCollectionChange}
147
147
-
/>
148
148
-
))}
149
149
-
</Fragment>
150
150
-
) : hasCollections ? (
151
151
-
<Fragment>
152
152
-
<Button
153
153
-
variant="light"
154
154
-
size="md"
155
155
-
color="grape"
156
156
-
radius="lg"
157
157
-
leftSection={<FiPlus size={22} />}
158
158
-
onClick={() => setIsDrawerOpen(true)}
159
159
-
>
160
160
-
Create new collection
161
161
-
</Button>
162
162
-
<CollectionSelectorItemList
163
163
-
collections={allCollections}
164
164
-
selectedCollections={props.selectedCollections}
165
165
-
onChange={handleCollectionChange}
166
166
-
/>
167
167
-
</Fragment>
118
118
+
{searchedCollections.data &&
119
119
+
(searchedCollections.data.collections.length === 0 ? (
120
120
+
<Alert
121
121
+
color="gray"
122
122
+
title={`No results found for "${search}"`}
123
123
+
/>
168
124
) : (
169
169
-
<Stack align="center" gap="xs">
170
170
-
<Text fz="lg" fw={600} c="gray">
171
171
-
No collections
172
172
-
</Text>
173
173
-
<Button
174
174
-
onClick={() => setIsDrawerOpen(true)}
175
175
-
variant="light"
176
176
-
color="gray"
177
177
-
rightSection={<FiPlus size={22} />}
178
178
-
>
179
179
-
Create a collection
180
180
-
</Button>
181
181
-
</Stack>
182
182
-
)}
183
183
-
</Stack>
184
184
-
</ScrollArea>
185
185
-
</Tabs.Panel>
125
125
+
<CollectionSelectorItemList
126
126
+
collections={searchedCollections.data.collections}
127
127
+
selectedCollections={props.selectedCollections}
128
128
+
onChange={handleCollectionChange}
129
129
+
/>
130
130
+
))}
131
131
+
</>
132
132
+
) : hasCollections ? (
133
133
+
<>
134
134
+
<Button
135
135
+
variant="light"
136
136
+
size="md"
137
137
+
color="grape"
138
138
+
radius="lg"
139
139
+
leftSection={<FiPlus size={22} />}
140
140
+
onClick={() => setIsDrawerOpen(true)}
141
141
+
>
142
142
+
Create new collection
143
143
+
</Button>
186
144
187
187
-
{/* Selected Collections Panel */}
188
188
-
<Tabs.Panel value="selected" my="xs">
189
189
-
<ScrollArea h={340} type="auto">
190
190
-
<Stack gap="xs">
191
191
-
{hasSelectedCollections ? (
145
145
+
{/* selected collections */}
146
146
+
{hasSelectedCollections && (
147
147
+
<Fragment>
148
148
+
<Text fw={600} fz={'sm'} c={'gray'}>
149
149
+
Selected Collections ({props.selectedCollections.length}
150
150
+
)
151
151
+
</Text>
192
152
<CollectionSelectorItemList
193
153
collections={props.selectedCollections}
194
154
selectedCollections={props.selectedCollections}
195
155
onChange={handleCollectionChange}
196
156
/>
197
197
-
) : (
198
198
-
<Alert color="gray" title="No collections selected" />
199
199
-
)}
200
200
-
</Stack>
201
201
-
</ScrollArea>
202
202
-
</Tabs.Panel>
203
203
-
</Tabs>
157
157
+
<Divider my="xs" />
158
158
+
</Fragment>
159
159
+
)}
204
160
205
205
-
<Group justify="space-between" gap="xs" grow>
206
206
-
<Button
207
207
-
variant="light"
208
208
-
color="gray"
209
209
-
size="md"
210
210
-
onClick={() => {
211
211
-
props.onSelectedCollectionsChange([]);
212
212
-
props.onClose();
213
213
-
}}
214
214
-
>
215
215
-
Cancel
216
216
-
</Button>
217
217
-
{hasSelectedCollections && (
218
218
-
<Button
219
219
-
variant="light"
220
220
-
color="grape"
221
221
-
size="md"
222
222
-
onClick={() => props.onSelectedCollectionsChange([])}
223
223
-
>
224
224
-
Clear
225
225
-
</Button>
161
161
+
{/* remaining collections */}
162
162
+
{unselectedCollections.length > 0 ? (
163
163
+
<CollectionSelectorItemList
164
164
+
collections={unselectedCollections}
165
165
+
selectedCollections={props.selectedCollections}
166
166
+
onChange={handleCollectionChange}
167
167
+
/>
168
168
+
) : (
169
169
+
!hasSelectedCollections && (
170
170
+
<Alert color="gray" title="No collections available" />
171
171
+
)
172
172
+
)}
173
173
+
</>
174
174
+
) : (
175
175
+
<Stack align="center" gap="xs">
176
176
+
<Text fz="lg" fw={600} c="gray">
177
177
+
No collections
178
178
+
</Text>
179
179
+
<Button
180
180
+
onClick={() => setIsDrawerOpen(true)}
181
181
+
variant="light"
182
182
+
color="gray"
183
183
+
rightSection={<FiPlus size={22} />}
184
184
+
>
185
185
+
Create a collection
186
186
+
</Button>
187
187
+
</Stack>
226
188
)}
227
227
-
<Button size="md" onClick={props.onClose}>
228
228
-
Save
229
229
-
</Button>
230
230
-
</Group>
231
231
-
</Stack>
232
232
-
</Container>
233
233
-
</Drawer>
189
189
+
</Stack>
190
190
+
</ScrollArea>
191
191
+
</Stack>
192
192
+
193
193
+
{/* Action Buttons */}
194
194
+
<Group justify="space-between" gap="xs" grow>
195
195
+
<Button
196
196
+
variant="light"
197
197
+
color="gray"
198
198
+
size="md"
199
199
+
onClick={() => props.onCancel()}
200
200
+
>
201
201
+
Cancel
202
202
+
</Button>
203
203
+
204
204
+
<Button
205
205
+
size="md"
206
206
+
onClick={(e) => {
207
207
+
props.onSave(e);
208
208
+
props.onClose();
209
209
+
}}
210
210
+
>
211
211
+
Save
212
212
+
</Button>
213
213
+
</Group>
214
214
+
</Stack>
234
215
235
216
<CreateCollectionDrawer
236
217
key={search}
+1
-1
src/webapp/features/composer/components/composerDrawer/ComposerDrawer.tsx
···
1
1
import { ActionIcon, Affix } from '@mantine/core';
2
2
-
import { Fragment, useEffect, useState } from 'react';
2
2
+
import { Fragment, useState } from 'react';
3
3
import { FiPlus } from 'react-icons/fi';
4
4
import AddCardDrawer from '@/features/cards/components/addCardDrawer/AddCardDrawer';
5
5
import { useMediaQuery } from '@mantine/hooks';
+1
-1
src/webapp/providers/mantine.tsx
···
13
13
export default function MantineProvider(props: Props) {
14
14
return (
15
15
<BaseProvider theme={theme}>
16
16
-
<Notifications position="bottom-left" />
16
16
+
<Notifications position="bottom-right" />
17
17
{props.children}
18
18
</BaseProvider>
19
19
);
+6
src/webapp/styles/theme.tsx
···
10
10
NavLink,
11
11
Spoiler,
12
12
TabsTab,
13
13
+
Tooltip,
13
14
} from '@mantine/core';
14
15
15
16
export const theme = createTheme({
···
116
117
defaultProps: {
117
118
fw: 500,
118
119
fz: 'md',
120
120
+
},
121
121
+
}),
122
122
+
Tooltip: Tooltip.extend({
123
123
+
defaultProps: {
124
124
+
position: 'top-start',
119
125
},
120
126
}),
121
127
},