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, 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" loading={addCard.isPending}>
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 <Suspense fallback={<CollectionSelectorSkeleton />}>
518 <CollectionSelector
519 isOpen={collectionSelectorOpened}
520 onCancel={() => {
521 setSelectedCollections(initialCollections);
522 toggleCollectionSelector();
523 }}
524 onClose={toggleCollectionSelector}
525 onSave={toggleCollectionSelector}
526 selectedCollections={selectedCollections}
527 onSelectedCollectionsChange={setSelectedCollections}
528 />
529 </Suspense>
530 </Container>
531 </Drawer>
532 </>
533 );
534}