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
18 kB 532 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, 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={'37rem'} 203 padding={'sm'} 204 position="bottom" 205 overlayProps={DEFAULT_OVERLAY_PROPS} 206 styles={{ 207 body: { flex: 1, display: 'flex', flexDirection: 'column' }, 208 content: { display: 'flex', flexDirection: 'column' }, 209 }} 210 > 211 <Drawer.Header> 212 <Stack gap="md" w="100%"> 213 <Drawer.Title fz={'xl'} fw={600} mx={'auto'}> 214 New 215 </Drawer.Title> 216 <SegmentedControl 217 value={mode} 218 onChange={(value) => setMode(value as ComposerMode)} 219 disabled={addCard.isPending || createCollection.isPending} 220 radius={'xl'} 221 mx="auto" 222 data={[ 223 { 224 value: 'card', 225 label: ( 226 <Center style={{ gap: 10 }}> 227 <FaRegNoteSticky size={16} /> 228 <span>Card</span> 229 </Center> 230 ), 231 }, 232 { 233 value: 'collection', 234 label: ( 235 <Center style={{ gap: 10 }}> 236 <BiCollection size={16} /> 237 <span>Collection</span> 238 </Center> 239 ), 240 }, 241 { 242 value: 'connection', 243 label: ( 244 <Center style={{ gap: 10 }}> 245 <TbPlugConnected size={16} /> 246 <span>Connection</span> 247 </Center> 248 ), 249 }, 250 ]} 251 /> 252 </Stack> 253 </Drawer.Header> 254 255 <Container 256 size={'sm'} 257 p={0} 258 w="100%" 259 style={{ flex: 1, display: 'flex', flexDirection: 'column' }} 260 > 261 {mode === 'connection' ? ( 262 <Suspense> 263 <AddConnectionForm onClose={props.onClose} /> 264 </Suspense> 265 ) : mode === 'card' ? ( 266 <form 267 onSubmit={handleAddCard} 268 style={{ flex: 1, display: 'flex', flexDirection: 'column' }} 269 > 270 <Stack gap={'xl'} style={{ flex: 1 }}> 271 <TextInput 272 id="url" 273 label="URL" 274 type="url" 275 placeholder="https://www.example.com" 276 variant="filled" 277 required 278 size="md" 279 leftSection={<IoMdLink size={22} />} 280 data-autofocus 281 key={cardForm.key('url')} 282 {...cardForm.getInputProps('url')} 283 /> 284 285 <Stack gap={0}> 286 <Flex justify="space-between"> 287 <Input.Label size="md" htmlFor="note"> 288 Note 289 </Input.Label> 290 <Text c={'gray'} aria-hidden> 291 {cardForm.getValues().note.length} / {MAX_NOTE_LENGTH} 292 </Text> 293 </Flex> 294 295 <Textarea 296 id="note" 297 placeholder="Add a note about this card" 298 variant="filled" 299 size="md" 300 rows={3} 301 maxLength={MAX_NOTE_LENGTH} 302 aria-describedby="note-char-remaining" 303 key={cardForm.key('note')} 304 {...cardForm.getInputProps('note')} 305 /> 306 <VisuallyHidden id="note-char-remaining" aria-live="polite"> 307 {`${MAX_NOTE_LENGTH - cardForm.getValues().note.length} characters remaining`} 308 </VisuallyHidden> 309 </Stack> 310 311 <Stack gap={5}> 312 <Text fw={500}> 313 Add to collections{' '} 314 {selectedCollections.length > 0 && 315 `(${selectedCollections.length})`} 316 </Text> 317 <Scroller> 318 <Group gap={'xs'} wrap="nowrap"> 319 <Button 320 onClick={toggleCollectionSelector} 321 variant="light" 322 color={'blue'} 323 leftSection={<BiCollection size={22} />} 324 disabled={addCard.isPending} 325 > 326 {myCollections.length === 0 327 ? 'Create a collection' 328 : 'Manage & Create'} 329 </Button> 330 331 {myCollections.map((col) => { 332 const marginUrl = getMarginUrl( 333 col.uri, 334 col.author?.handle, 335 ); 336 return ( 337 <Button 338 key={col.id} 339 disabled={addCard.isPending} 340 variant="light" 341 color={ 342 selectedCollections.some((c) => c.id === col.id) 343 ? 'grape' 344 : 'gray' 345 } 346 rightSection={ 347 selectedCollections.some( 348 (c) => c.id === col.id, 349 ) ? ( 350 <IoMdCheckmark /> 351 ) : null 352 } 353 leftSection={ 354 isMarginUri(col.uri) ? ( 355 <MarginLogo size={12} marginUrl={marginUrl} /> 356 ) : col.accessType === 357 CollectionAccessType.OPEN ? ( 358 <ThemeIcon 359 variant="light" 360 radius={'xl'} 361 size={'xs'} 362 color="green" 363 > 364 <FaSeedling size={8} /> 365 </ThemeIcon> 366 ) : undefined 367 } 368 onClick={() => { 369 setSelectedCollections((prev) => { 370 if (prev.some((c) => c.id === col.id)) { 371 return prev.filter((c) => c.id !== col.id); 372 } 373 return [...prev, col]; 374 }); 375 }} 376 > 377 {col.name} 378 </Button> 379 ); 380 })} 381 </Group> 382 </Scroller> 383 </Stack> 384 385 <Group 386 justify="space-between" 387 gap={'xs'} 388 grow 389 mt="auto" 390 mb="md" 391 > 392 <Button 393 variant="light" 394 size="md" 395 color={'gray'} 396 onClick={() => { 397 props.onClose(); 398 setSelectedCollections(initialCollections); 399 }} 400 > 401 Cancel 402 </Button> 403 <Button type="submit" size="md"> 404 Add card 405 </Button> 406 </Group> 407 </Stack> 408 </form> 409 ) : ( 410 <form 411 onSubmit={handleCreateCollection} 412 style={{ flex: 1, display: 'flex', flexDirection: 'column' }} 413 > 414 <Stack gap={'xl'} style={{ flex: 1 }}> 415 <TextInput 416 id="name" 417 label="Name" 418 type="text" 419 placeholder="Collection name" 420 variant="filled" 421 size="md" 422 required 423 maxLength={100} 424 data-autofocus 425 key={collectionForm.key('name')} 426 {...collectionForm.getInputProps('name')} 427 /> 428 429 <Textarea 430 id="description" 431 label="Description" 432 placeholder="Describe what this collection is about" 433 variant="filled" 434 size="md" 435 rows={3} 436 maxLength={500} 437 key={collectionForm.key('description')} 438 {...collectionForm.getInputProps('description')} 439 /> 440 441 <Select 442 variant="filled" 443 size="md" 444 label="Collaboration" 445 leftSection={ 446 collectionForm.getValues().accessType === 447 CollectionAccessType.OPEN ? ( 448 <ThemeIcon 449 size={'md'} 450 variant="light" 451 color={'green'} 452 radius={'xl'} 453 > 454 <FaSeedling size={14} /> 455 </ThemeIcon> 456 ) : null 457 } 458 allowDeselect={false} 459 defaultValue={CollectionAccessType.CLOSED} 460 data={[ 461 { 462 value: CollectionAccessType.CLOSED, 463 label: 'Personal — Only you can add', 464 }, 465 { 466 value: CollectionAccessType.OPEN, 467 label: 'Open — Anyone can add', 468 }, 469 ]} 470 {...collectionForm.getInputProps('accessType')} 471 /> 472 473 <Group 474 justify="space-between" 475 gap={'xs'} 476 grow 477 mt="auto" 478 mb="md" 479 > 480 <Button 481 variant="light" 482 size="md" 483 color={'gray'} 484 onClick={props.onClose} 485 > 486 Cancel 487 </Button> 488 <Button 489 type="submit" 490 size="md" 491 loading={createCollection.isPending} 492 > 493 Create 494 </Button> 495 </Group> 496 </Stack> 497 </form> 498 )} 499 </Container> 500 </Drawer> 501 502 <Drawer 503 opened={collectionSelectorOpened} 504 onClose={toggleCollectionSelector} 505 withCloseButton={false} 506 position="bottom" 507 padding={'sm'} 508 size={'30rem'} 509 overlayProps={DEFAULT_OVERLAY_PROPS} 510 > 511 <Drawer.Header> 512 <Drawer.Title fz={'xl'} fw={600} mx={'auto'}> 513 Add to collections 514 </Drawer.Title> 515 </Drawer.Header> 516 <Container size={'xs'} p={0}> 517 <CollectionSelector 518 isOpen={collectionSelectorOpened} 519 onCancel={() => { 520 setSelectedCollections(initialCollections); 521 toggleCollectionSelector(); 522 }} 523 onClose={toggleCollectionSelector} 524 onSave={toggleCollectionSelector} 525 selectedCollections={selectedCollections} 526 onSelectedCollectionsChange={setSelectedCollections} 527 /> 528 </Container> 529 </Drawer> 530 </> 531 ); 532}