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