This repository has no description
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}