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 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}