This repository has no description
0

Configure Feed

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

semble / src / webapp / features / cards / components / addCardDrawer / AddCardForm.tsx
9.2 kB 285 lines
1'use client'; 2 3import { 4 Button, 5 Drawer, 6 Flex, 7 Group, 8 Input, 9 ScrollArea, 10 Stack, 11 Text, 12 Textarea, 13 TextInput, 14 ThemeIcon, 15 VisuallyHidden, 16 Container, 17} from '@mantine/core'; 18import { useForm } from '@mantine/form'; 19import { notifications } from '@mantine/notifications'; 20import useAddCard from '../../lib/mutations/useAddCard'; 21import CollectionSelector from '@/features/collections/components/collectionSelector/CollectionSelector'; 22import { Suspense, useEffect, useState } from 'react'; 23import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; 24import { useDisclosure } from '@mantine/hooks'; 25import { BiCollection } from 'react-icons/bi'; 26import { IoMdCheckmark, IoMdLink } from 'react-icons/io'; 27import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; 28import { track } from '@vercel/analytics'; 29import useMyCollections from '@/features/collections/lib/queries/useMyCollections'; 30import { isMarginUri, getMarginUrl } from '@/lib/utils/margin'; 31import MarginLogo from '@/components/MarginLogo'; 32import { Collection, CollectionAccessType } from '@semble/types'; 33import { FaSeedling } from 'react-icons/fa6'; 34import { CardSaveSource } from '@/features/analytics/types'; 35import { usePathname } from 'next/navigation'; 36 37interface Props { 38 onClose: () => void; 39 selectedCollection?: Collection; 40 initialUrl?: string; 41} 42 43export default function AddCardForm(props: Props) { 44 const pathname = usePathname(); 45 const [collectionSelectorOpened, { toggle: toggleCollectionSelector }] = 46 useDisclosure(false); 47 const initialCollections = props.selectedCollection 48 ? [props.selectedCollection] 49 : []; 50 const [selectedCollections, setSelectedCollections] = 51 useState(initialCollections); 52 53 const { data: collections } = useMyCollections({ limit: 30 }); 54 const allCollections = 55 collections?.pages.flatMap((page) => page.collections ?? []) ?? []; 56 57 // Put selectedCollection first, then others 58 const myCollections = props.selectedCollection 59 ? [ 60 props.selectedCollection, 61 ...allCollections.filter((c) => c.id !== props.selectedCollection?.id), 62 ] 63 : allCollections; 64 65 const addCard = useAddCard({ 66 saveSource: CardSaveSource.ADD_CARD_DRAWER, 67 pagePath: pathname, 68 }); 69 70 const form = useForm({ 71 initialValues: { 72 url: props.initialUrl || '', 73 note: '', 74 collections: selectedCollections, 75 }, 76 }); 77 78 const MAX_NOTE_LENGTH = 500; 79 80 useEffect(() => { 81 if (props.initialUrl) { 82 form.setValues({ url: props.initialUrl }); 83 } 84 }, [props.initialUrl]); 85 86 const handleAddCard = (e: React.FormEvent) => { 87 e.preventDefault(); 88 track('add new card'); 89 90 // Capture values before any state changes 91 const cardData = { 92 url: form.getValues().url, 93 note: form.getValues().note, 94 collectionIds: selectedCollections.map((c) => c.id), 95 }; 96 97 // Show loading toast immediately 98 const notificationId = `add-card-${Date.now()}`; 99 notifications.show({ 100 id: notificationId, 101 loading: true, 102 title: 'Adding card...', 103 message: 'Please wait', 104 position: 'top-center', 105 autoClose: false, 106 withCloseButton: false, 107 }); 108 109 // Close drawer immediately 110 props.onClose(); 111 setSelectedCollections(initialCollections); 112 form.reset(); 113 114 addCard.mutate({ ...cardData, notificationId }); 115 }; 116 117 return ( 118 <> 119 <form onSubmit={handleAddCard}> 120 <Stack gap={'xl'}> 121 <TextInput 122 id="url" 123 label="URL" 124 type="url" 125 placeholder="https://www.example.com" 126 variant="filled" 127 required 128 size="md" 129 leftSection={<IoMdLink size={22} />} 130 key={form.key('url')} 131 {...form.getInputProps('url')} 132 /> 133 134 <Stack gap={0}> 135 <Flex justify="space-between"> 136 <Input.Label size="md" htmlFor="note"> 137 Note 138 </Input.Label> 139 <Text c={'gray'} aria-hidden> 140 {form.getValues().note.length} / {MAX_NOTE_LENGTH} 141 </Text> 142 </Flex> 143 144 <Textarea 145 id="note" 146 placeholder="Add a note about this card" 147 variant="filled" 148 size="md" 149 rows={3} 150 maxLength={MAX_NOTE_LENGTH} 151 aria-describedby="note-char-remaining" 152 key={form.key('note')} 153 {...form.getInputProps('note')} 154 /> 155 <VisuallyHidden id="note-char-remaining" aria-live="polite"> 156 {`${MAX_NOTE_LENGTH - form.getValues().note.length} characters remaining`} 157 </VisuallyHidden> 158 </Stack> 159 160 <Stack gap={5}> 161 <Text fw={500}> 162 Add to collections{' '} 163 {selectedCollections.length > 0 && 164 `(${selectedCollections.length})`} 165 </Text> 166 <ScrollArea.Autosize 167 type="hover" 168 scrollbars="x" 169 offsetScrollbars={true} 170 > 171 <Group gap={'xs'} wrap="nowrap"> 172 <Button 173 disabled={addCard.isPending} 174 onClick={toggleCollectionSelector} 175 variant="light" 176 color={'blue'} 177 leftSection={<BiCollection size={22} />} 178 > 179 {myCollections.length === 0 180 ? 'Create a collection' 181 : 'Manage & Create'} 182 </Button> 183 184 {myCollections.map((col) => { 185 const marginUrl = getMarginUrl(col.uri, col.author?.handle); 186 return ( 187 <Button 188 key={col.id} 189 disabled={addCard.isPending} 190 variant="light" 191 color={ 192 selectedCollections.some((c) => c.id === col.id) 193 ? 'grape' 194 : 'gray' 195 } 196 rightSection={ 197 selectedCollections.some((c) => c.id === col.id) ? ( 198 <IoMdCheckmark /> 199 ) : null 200 } 201 leftSection={ 202 isMarginUri(col.uri) ? ( 203 <MarginLogo size={12} marginUrl={marginUrl} /> 204 ) : col.accessType === CollectionAccessType.OPEN ? ( 205 <ThemeIcon 206 variant="light" 207 radius={'xl'} 208 size={'xs'} 209 color="green" 210 > 211 <FaSeedling size={8} /> 212 </ThemeIcon> 213 ) : undefined 214 } 215 onClick={() => { 216 setSelectedCollections((prev) => { 217 // already selected, remove 218 if (prev.some((c) => c.id === col.id)) { 219 return prev.filter((c) => c.id !== col.id); 220 } 221 // not selected, add it 222 return [...prev, col]; 223 }); 224 }} 225 > 226 {col.name} 227 </Button> 228 ); 229 })} 230 </Group> 231 </ScrollArea.Autosize> 232 </Stack> 233 234 <Group justify="space-between" gap={'xs'} grow> 235 <Button 236 variant="light" 237 size="md" 238 color={'gray'} 239 onClick={() => { 240 props.onClose(); 241 setSelectedCollections(initialCollections); 242 }} 243 > 244 Cancel 245 </Button> 246 <Button type="submit" size="md" loading={addCard.isPending}> 247 Add card 248 </Button> 249 </Group> 250 </Stack> 251 </form> 252 253 <Drawer 254 opened={collectionSelectorOpened} 255 onClose={toggleCollectionSelector} 256 withCloseButton={false} 257 position="bottom" 258 padding={'sm'} 259 size={'30rem'} 260 overlayProps={DEFAULT_OVERLAY_PROPS} 261 > 262 <Drawer.Header> 263 <Drawer.Title fz={'xl'} fw={600} mx={'auto'}> 264 Add to collections 265 </Drawer.Title> 266 </Drawer.Header> 267 <Container size={'xs'} p={0}> 268 <Suspense fallback={<CollectionSelectorSkeleton />}> 269 <CollectionSelector 270 isOpen={collectionSelectorOpened} 271 onCancel={() => { 272 setSelectedCollections(initialCollections); 273 toggleCollectionSelector(); 274 }} 275 onClose={toggleCollectionSelector} 276 onSave={toggleCollectionSelector} 277 selectedCollections={selectedCollections} 278 onSelectedCollectionsChange={setSelectedCollections} 279 /> 280 </Suspense> 281 </Container> 282 </Drawer> 283 </> 284 ); 285}