This repository has no description
9.2 kB
285 lines
1'use client';
2
3import {
4 Button,
5 Drawer,
6 Flex,
7 Group,
8 Input,
9 ScrollArea,
10 Stack,
11 Text,
12 Textarea,
13 TextInput,
14 ThemeIcon,
15 VisuallyHidden,
16 Container,
17} from '@mantine/core';
18import { useForm } from '@mantine/form';
19import { notifications } from '@mantine/notifications';
20import useAddCard from '../../lib/mutations/useAddCard';
21import CollectionSelector from '@/features/collections/components/collectionSelector/CollectionSelector';
22import { Suspense, useEffect, useState } from 'react';
23import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector';
24import { useDisclosure } from '@mantine/hooks';
25import { BiCollection } from 'react-icons/bi';
26import { IoMdCheckmark, IoMdLink } from 'react-icons/io';
27import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays';
28import { track } from '@vercel/analytics';
29import useMyCollections from '@/features/collections/lib/queries/useMyCollections';
30import { isMarginUri, getMarginUrl } from '@/lib/utils/margin';
31import MarginLogo from '@/components/MarginLogo';
32import { Collection, CollectionAccessType } from '@semble/types';
33import { FaSeedling } from 'react-icons/fa6';
34import { CardSaveSource } from '@/features/analytics/types';
35import { usePathname } from 'next/navigation';
36
37interface Props {
38 onClose: () => void;
39 selectedCollection?: Collection;
40 initialUrl?: string;
41}
42
43export default function AddCardForm(props: Props) {
44 const pathname = usePathname();
45 const [collectionSelectorOpened, { toggle: toggleCollectionSelector }] =
46 useDisclosure(false);
47 const initialCollections = props.selectedCollection
48 ? [props.selectedCollection]
49 : [];
50 const [selectedCollections, setSelectedCollections] =
51 useState(initialCollections);
52
53 const { data: collections } = useMyCollections({ limit: 30 });
54 const allCollections =
55 collections?.pages.flatMap((page) => page.collections ?? []) ?? [];
56
57 // Put selectedCollection first, then others
58 const myCollections = props.selectedCollection
59 ? [
60 props.selectedCollection,
61 ...allCollections.filter((c) => c.id !== props.selectedCollection?.id),
62 ]
63 : allCollections;
64
65 const addCard = useAddCard({
66 saveSource: CardSaveSource.ADD_CARD_DRAWER,
67 pagePath: pathname,
68 });
69
70 const form = useForm({
71 initialValues: {
72 url: props.initialUrl || '',
73 note: '',
74 collections: selectedCollections,
75 },
76 });
77
78 const MAX_NOTE_LENGTH = 500;
79
80 useEffect(() => {
81 if (props.initialUrl) {
82 form.setValues({ url: props.initialUrl });
83 }
84 }, [props.initialUrl]);
85
86 const handleAddCard = (e: React.FormEvent) => {
87 e.preventDefault();
88 track('add new card');
89
90 // Capture values before any state changes
91 const cardData = {
92 url: form.getValues().url,
93 note: form.getValues().note,
94 collectionIds: selectedCollections.map((c) => c.id),
95 };
96
97 // Show loading toast immediately
98 const notificationId = `add-card-${Date.now()}`;
99 notifications.show({
100 id: notificationId,
101 loading: true,
102 title: 'Adding card...',
103 message: 'Please wait',
104 position: 'top-center',
105 autoClose: false,
106 withCloseButton: false,
107 });
108
109 // Close drawer immediately
110 props.onClose();
111 setSelectedCollections(initialCollections);
112 form.reset();
113
114 addCard.mutate({ ...cardData, notificationId });
115 };
116
117 return (
118 <>
119 <form onSubmit={handleAddCard}>
120 <Stack gap={'xl'}>
121 <TextInput
122 id="url"
123 label="URL"
124 type="url"
125 placeholder="https://www.example.com"
126 variant="filled"
127 required
128 size="md"
129 leftSection={<IoMdLink size={22} />}
130 key={form.key('url')}
131 {...form.getInputProps('url')}
132 />
133
134 <Stack gap={0}>
135 <Flex justify="space-between">
136 <Input.Label size="md" htmlFor="note">
137 Note
138 </Input.Label>
139 <Text c={'gray'} aria-hidden>
140 {form.getValues().note.length} / {MAX_NOTE_LENGTH}
141 </Text>
142 </Flex>
143
144 <Textarea
145 id="note"
146 placeholder="Add a note about this card"
147 variant="filled"
148 size="md"
149 rows={3}
150 maxLength={MAX_NOTE_LENGTH}
151 aria-describedby="note-char-remaining"
152 key={form.key('note')}
153 {...form.getInputProps('note')}
154 />
155 <VisuallyHidden id="note-char-remaining" aria-live="polite">
156 {`${MAX_NOTE_LENGTH - form.getValues().note.length} characters remaining`}
157 </VisuallyHidden>
158 </Stack>
159
160 <Stack gap={5}>
161 <Text fw={500}>
162 Add to collections{' '}
163 {selectedCollections.length > 0 &&
164 `(${selectedCollections.length})`}
165 </Text>
166 <ScrollArea.Autosize
167 type="hover"
168 scrollbars="x"
169 offsetScrollbars={true}
170 >
171 <Group gap={'xs'} wrap="nowrap">
172 <Button
173 disabled={addCard.isPending}
174 onClick={toggleCollectionSelector}
175 variant="light"
176 color={'blue'}
177 leftSection={<BiCollection size={22} />}
178 >
179 {myCollections.length === 0
180 ? 'Create a collection'
181 : 'Manage & Create'}
182 </Button>
183
184 {myCollections.map((col) => {
185 const marginUrl = getMarginUrl(col.uri, col.author?.handle);
186 return (
187 <Button
188 key={col.id}
189 disabled={addCard.isPending}
190 variant="light"
191 color={
192 selectedCollections.some((c) => c.id === col.id)
193 ? 'grape'
194 : 'gray'
195 }
196 rightSection={
197 selectedCollections.some((c) => c.id === col.id) ? (
198 <IoMdCheckmark />
199 ) : null
200 }
201 leftSection={
202 isMarginUri(col.uri) ? (
203 <MarginLogo size={12} marginUrl={marginUrl} />
204 ) : col.accessType === CollectionAccessType.OPEN ? (
205 <ThemeIcon
206 variant="light"
207 radius={'xl'}
208 size={'xs'}
209 color="green"
210 >
211 <FaSeedling size={8} />
212 </ThemeIcon>
213 ) : undefined
214 }
215 onClick={() => {
216 setSelectedCollections((prev) => {
217 // already selected, remove
218 if (prev.some((c) => c.id === col.id)) {
219 return prev.filter((c) => c.id !== col.id);
220 }
221 // not selected, add it
222 return [...prev, col];
223 });
224 }}
225 >
226 {col.name}
227 </Button>
228 );
229 })}
230 </Group>
231 </ScrollArea.Autosize>
232 </Stack>
233
234 <Group justify="space-between" gap={'xs'} grow>
235 <Button
236 variant="light"
237 size="md"
238 color={'gray'}
239 onClick={() => {
240 props.onClose();
241 setSelectedCollections(initialCollections);
242 }}
243 >
244 Cancel
245 </Button>
246 <Button type="submit" size="md" loading={addCard.isPending}>
247 Add card
248 </Button>
249 </Group>
250 </Stack>
251 </form>
252
253 <Drawer
254 opened={collectionSelectorOpened}
255 onClose={toggleCollectionSelector}
256 withCloseButton={false}
257 position="bottom"
258 padding={'sm'}
259 size={'30rem'}
260 overlayProps={DEFAULT_OVERLAY_PROPS}
261 >
262 <Drawer.Header>
263 <Drawer.Title fz={'xl'} fw={600} mx={'auto'}>
264 Add to collections
265 </Drawer.Title>
266 </Drawer.Header>
267 <Container size={'xs'} p={0}>
268 <Suspense fallback={<CollectionSelectorSkeleton />}>
269 <CollectionSelector
270 isOpen={collectionSelectorOpened}
271 onCancel={() => {
272 setSelectedCollections(initialCollections);
273 toggleCollectionSelector();
274 }}
275 onClose={toggleCollectionSelector}
276 onSave={toggleCollectionSelector}
277 selectedCollections={selectedCollections}
278 onSelectedCollectionsChange={setSelectedCollections}
279 />
280 </Suspense>
281 </Container>
282 </Drawer>
283 </>
284 );
285}