This repository has no description
0

Configure Feed

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

semble / src / webapp / features / composer / components / Composer.tsx
17 kB 511 lines
1'use client'; 2 3import { 4 Button, 5 Center, 6 Container, 7 Drawer, 8 Flex, 9 Group, 10 Input, 11 ScrollArea, 12 Select, 13 Stack, 14 SegmentedControl, 15 Text, 16 Textarea, 17 TextInput, 18 ThemeIcon, 19 VisuallyHidden, 20} from '@mantine/core'; 21import { useState, useEffect, Suspense } from 'react'; 22import { Collection, CollectionAccessType } from '@semble/types'; 23import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays'; 24import { useForm } from '@mantine/form'; 25import { notifications } from '@mantine/notifications'; 26import useAddCard from '@/features/cards/lib/mutations/useAddCard'; 27import useCreateCollection from '@/features/collections/lib/mutations/useCreateCollection'; 28import CollectionSelector from '@/features/collections/components/collectionSelector/CollectionSelector'; 29import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector'; 30import { useDisclosure } from '@mantine/hooks'; 31import { BiCollection } from 'react-icons/bi'; 32import { IoMdCheckmark, IoMdLink } from 'react-icons/io'; 33import { track } from '@vercel/analytics'; 34import useMyCollections from '@/features/collections/lib/queries/useMyCollections'; 35import { isMarginUri, getMarginUrl } from '@/lib/utils/margin'; 36import MarginLogo from '@/components/MarginLogo'; 37import { FaSeedling } from 'react-icons/fa6'; 38import { FaRegNoteSticky } from 'react-icons/fa6'; 39import { CardSaveSource } from '@/features/analytics/types'; 40import { usePathname } from 'next/navigation'; 41import { BsExclamation } from 'react-icons/bs'; 42import AddConnectionForm from '@/features/connections/components/addConnectionDrawer/AddConnectionForm'; 43import { TbPlugConnected } from 'react-icons/tb'; 44 45type ComposerMode = 'card' | 'collection' | 'connection'; 46 47interface Props { 48 isOpen: boolean; 49 onClose: () => void; 50 initialMode?: ComposerMode; 51 selectedCollection?: Collection; 52 initialUrl?: string; 53 initialCollectionName?: string; 54 initialCollectionAccessType?: CollectionAccessType; 55 onCollectionCreate?: () => void; 56} 57 58export default function Composer(props: Props) { 59 const pathname = usePathname(); 60 const [mode, setMode] = useState<ComposerMode>(props.initialMode ?? 'card'); 61 62 // Card form state 63 const [collectionSelectorOpened, { toggle: toggleCollectionSelector }] = 64 useDisclosure(false); 65 const initialCollections = props.selectedCollection 66 ? [props.selectedCollection] 67 : []; 68 const [selectedCollections, setSelectedCollections] = 69 useState(initialCollections); 70 71 const { data: collections } = useMyCollections({ limit: 30 }); 72 const allCollections = 73 collections?.pages.flatMap((page) => page.collections ?? []) ?? []; 74 75 const myCollections = props.selectedCollection 76 ? [ 77 props.selectedCollection, 78 ...allCollections.filter((c) => c.id !== props.selectedCollection?.id), 79 ] 80 : allCollections; 81 82 const addCard = useAddCard({ 83 saveSource: CardSaveSource.ADD_CARD_DRAWER, 84 pagePath: pathname, 85 }); 86 87 const cardForm = useForm({ 88 initialValues: { 89 url: props.initialUrl || '', 90 note: '', 91 collections: selectedCollections, 92 }, 93 }); 94 95 // Collection form state 96 const createCollection = useCreateCollection(); 97 const collectionForm = useForm({ 98 initialValues: { 99 name: props.initialCollectionName ?? '', 100 description: '', 101 accessType: 102 props.initialCollectionAccessType ?? CollectionAccessType.CLOSED, 103 }, 104 }); 105 106 const MAX_NOTE_LENGTH = 500; 107 108 useEffect(() => { 109 if (props.initialMode) { 110 setMode(props.initialMode); 111 } 112 }, [props.initialMode]); 113 114 useEffect(() => { 115 if (props.initialUrl) { 116 cardForm.setValues({ url: props.initialUrl }); 117 } 118 }, [props.initialUrl]); 119 120 // Reset state when drawer closes 121 useEffect(() => { 122 if (!props.isOpen) { 123 cardForm.reset(); 124 collectionForm.reset(); 125 setSelectedCollections(initialCollections); 126 setMode('card'); 127 } 128 }, [props.isOpen]); 129 130 const handleAddCard = (e: React.FormEvent) => { 131 e.preventDefault(); 132 track('add new card'); 133 134 // Capture values before any state changes 135 const cardData = { 136 url: cardForm.getValues().url, 137 note: cardForm.getValues().note, 138 collectionIds: selectedCollections.map((c) => c.id), 139 }; 140 141 // Show loading toast immediately 142 const notificationId = `add-card-${Date.now()}`; 143 notifications.show({ 144 id: notificationId, 145 loading: true, 146 title: 'Adding card...', 147 message: 'Please wait', 148 position: 'top-center', 149 autoClose: false, 150 withCloseButton: false, 151 }); 152 153 // Close drawer immediately 154 props.onClose(); 155 setSelectedCollections(initialCollections); 156 window.history.replaceState({}, '', window.location.pathname); 157 cardForm.reset(); 158 159 addCard.mutate({ ...cardData, notificationId }); 160 }; 161 162 const handleCreateCollection = (e: React.FormEvent) => { 163 e.preventDefault(); 164 e.stopPropagation(); 165 166 // Capture values before any state changes 167 const collectionData = { 168 name: collectionForm.getValues().name, 169 description: collectionForm.getValues().description, 170 accessType: collectionForm.getValues().accessType, 171 }; 172 173 // Close drawer immediately 174 props.onClose(); 175 if (props.onCollectionCreate) { 176 props.onCollectionCreate(); 177 } 178 collectionForm.reset(); 179 180 createCollection.mutate(collectionData, { 181 onError: () => { 182 notifications.show({ 183 color: 'red', 184 title: 'Error', 185 message: 'Could not create collection', 186 position: 'top-center', 187 loading: false, 188 autoClose: false, 189 withCloseButton: true, 190 icon: <BsExclamation />, 191 }); 192 }, 193 }); 194 }; 195 196 return ( 197 <> 198 <Drawer 199 opened={props.isOpen} 200 onClose={props.onClose} 201 withCloseButton={false} 202 size={'35rem'} 203 padding={'sm'} 204 position="bottom" 205 overlayProps={DEFAULT_OVERLAY_PROPS} 206 > 207 <Drawer.Header> 208 <Stack gap="md" w="100%"> 209 <Drawer.Title fz={'xl'} fw={600} mx={'auto'}> 210 New 211 </Drawer.Title> 212 <SegmentedControl 213 value={mode} 214 onChange={(value) => setMode(value as ComposerMode)} 215 disabled={addCard.isPending || createCollection.isPending} 216 radius={'xl'} 217 mx="auto" 218 data={[ 219 { 220 value: 'card', 221 label: ( 222 <Center style={{ gap: 10 }}> 223 <FaRegNoteSticky size={16} /> 224 <span>Card</span> 225 </Center> 226 ), 227 }, 228 { 229 value: 'collection', 230 label: ( 231 <Center style={{ gap: 10 }}> 232 <BiCollection size={16} /> 233 <span>Collection</span> 234 </Center> 235 ), 236 }, 237 { 238 value: 'connection', 239 label: ( 240 <Center style={{ gap: 10 }}> 241 <TbPlugConnected size={16} /> 242 <span>Connection</span> 243 </Center> 244 ), 245 }, 246 ]} 247 /> 248 </Stack> 249 </Drawer.Header> 250 251 <Container size={'sm'} p={0}> 252 {mode === 'connection' ? ( 253 <Suspense> 254 <AddConnectionForm onClose={props.onClose} /> 255 </Suspense> 256 ) : mode === 'card' ? ( 257 <form onSubmit={handleAddCard}> 258 <Stack gap={'xl'}> 259 <TextInput 260 id="url" 261 label="URL" 262 type="url" 263 placeholder="https://www.example.com" 264 variant="filled" 265 required 266 size="md" 267 leftSection={<IoMdLink size={22} />} 268 data-autofocus 269 key={cardForm.key('url')} 270 {...cardForm.getInputProps('url')} 271 /> 272 273 <Stack gap={0}> 274 <Flex justify="space-between"> 275 <Input.Label size="md" htmlFor="note"> 276 Note 277 </Input.Label> 278 <Text c={'gray'} aria-hidden> 279 {cardForm.getValues().note.length} / {MAX_NOTE_LENGTH} 280 </Text> 281 </Flex> 282 283 <Textarea 284 id="note" 285 placeholder="Add a note about this card" 286 variant="filled" 287 size="md" 288 rows={3} 289 maxLength={MAX_NOTE_LENGTH} 290 aria-describedby="note-char-remaining" 291 key={cardForm.key('note')} 292 {...cardForm.getInputProps('note')} 293 /> 294 <VisuallyHidden id="note-char-remaining" aria-live="polite"> 295 {`${MAX_NOTE_LENGTH - cardForm.getValues().note.length} characters remaining`} 296 </VisuallyHidden> 297 </Stack> 298 299 <Stack gap={5}> 300 <Text fw={500}> 301 Add to collections{' '} 302 {selectedCollections.length > 0 && 303 `(${selectedCollections.length})`} 304 </Text> 305 <ScrollArea.Autosize 306 type="hover" 307 scrollbars="x" 308 offsetScrollbars={true} 309 > 310 <Group gap={'xs'} wrap="nowrap"> 311 <Button 312 onClick={toggleCollectionSelector} 313 variant="light" 314 color={'blue'} 315 leftSection={<BiCollection size={22} />} 316 disabled={addCard.isPending} 317 > 318 {myCollections.length === 0 319 ? 'Create a collection' 320 : 'Manage & Create'} 321 </Button> 322 323 {myCollections.map((col) => { 324 const marginUrl = getMarginUrl( 325 col.uri, 326 col.author?.handle, 327 ); 328 return ( 329 <Button 330 key={col.id} 331 disabled={addCard.isPending} 332 variant="light" 333 color={ 334 selectedCollections.some((c) => c.id === col.id) 335 ? 'grape' 336 : 'gray' 337 } 338 rightSection={ 339 selectedCollections.some( 340 (c) => c.id === col.id, 341 ) ? ( 342 <IoMdCheckmark /> 343 ) : null 344 } 345 leftSection={ 346 isMarginUri(col.uri) ? ( 347 <MarginLogo size={12} marginUrl={marginUrl} /> 348 ) : col.accessType === 349 CollectionAccessType.OPEN ? ( 350 <ThemeIcon 351 variant="light" 352 radius={'xl'} 353 size={'xs'} 354 color="green" 355 > 356 <FaSeedling size={8} /> 357 </ThemeIcon> 358 ) : undefined 359 } 360 onClick={() => { 361 setSelectedCollections((prev) => { 362 if (prev.some((c) => c.id === col.id)) { 363 return prev.filter((c) => c.id !== col.id); 364 } 365 return [...prev, col]; 366 }); 367 }} 368 > 369 {col.name} 370 </Button> 371 ); 372 })} 373 </Group> 374 </ScrollArea.Autosize> 375 </Stack> 376 377 <Group justify="space-between" gap={'xs'} grow> 378 <Button 379 variant="light" 380 size="md" 381 color={'gray'} 382 onClick={() => { 383 props.onClose(); 384 setSelectedCollections(initialCollections); 385 }} 386 > 387 Cancel 388 </Button> 389 <Button type="submit" size="md" loading={addCard.isPending}> 390 Add card 391 </Button> 392 </Group> 393 </Stack> 394 </form> 395 ) : ( 396 <form onSubmit={handleCreateCollection}> 397 <Stack gap={'xl'}> 398 <TextInput 399 id="name" 400 label="Name" 401 type="text" 402 placeholder="Collection name" 403 variant="filled" 404 size="md" 405 required 406 maxLength={100} 407 data-autofocus 408 key={collectionForm.key('name')} 409 {...collectionForm.getInputProps('name')} 410 /> 411 412 <Textarea 413 id="description" 414 label="Description" 415 placeholder="Describe what this collection is about" 416 variant="filled" 417 size="md" 418 rows={3} 419 maxLength={500} 420 key={collectionForm.key('description')} 421 {...collectionForm.getInputProps('description')} 422 /> 423 424 <Select 425 variant="filled" 426 size="md" 427 label="Collaboration" 428 leftSection={ 429 collectionForm.getValues().accessType === 430 CollectionAccessType.OPEN ? ( 431 <ThemeIcon 432 size={'md'} 433 variant="light" 434 color={'green'} 435 radius={'xl'} 436 > 437 <FaSeedling size={14} /> 438 </ThemeIcon> 439 ) : null 440 } 441 allowDeselect={false} 442 defaultValue={CollectionAccessType.CLOSED} 443 data={[ 444 { 445 value: CollectionAccessType.CLOSED, 446 label: 'Personal — Only you can add', 447 }, 448 { 449 value: CollectionAccessType.OPEN, 450 label: 'Open — Anyone can add', 451 }, 452 ]} 453 {...collectionForm.getInputProps('accessType')} 454 /> 455 456 <Group justify="space-between" gap={'xs'} grow> 457 <Button 458 variant="light" 459 size="md" 460 color={'gray'} 461 onClick={props.onClose} 462 > 463 Cancel 464 </Button> 465 <Button 466 type="submit" 467 size="md" 468 loading={createCollection.isPending} 469 > 470 Create 471 </Button> 472 </Group> 473 </Stack> 474 </form> 475 )} 476 </Container> 477 </Drawer> 478 479 <Drawer 480 opened={collectionSelectorOpened} 481 onClose={toggleCollectionSelector} 482 withCloseButton={false} 483 position="bottom" 484 padding={'sm'} 485 size={'30rem'} 486 overlayProps={DEFAULT_OVERLAY_PROPS} 487 > 488 <Drawer.Header> 489 <Drawer.Title fz={'xl'} fw={600} mx={'auto'}> 490 Add to collections 491 </Drawer.Title> 492 </Drawer.Header> 493 <Container size={'xs'} p={0}> 494 <Suspense fallback={<CollectionSelectorSkeleton />}> 495 <CollectionSelector 496 isOpen={collectionSelectorOpened} 497 onCancel={() => { 498 setSelectedCollections(initialCollections); 499 toggleCollectionSelector(); 500 }} 501 onClose={toggleCollectionSelector} 502 onSave={toggleCollectionSelector} 503 selectedCollections={selectedCollections} 504 onSelectedCollectionsChange={setSelectedCollections} 505 /> 506 </Suspense> 507 </Container> 508 </Drawer> 509 </> 510 ); 511}