A social knowledge tool for researchers built on ATProto

feat: remove and add collections from url card

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