This repository has no description
10 kB
323 lines
1'use client';
2
3import {
4 Button,
5 Drawer,
6 Flex,
7 Group,
8 Input,
9 Stack,
10 Text,
11 Textarea,
12 ThemeIcon,
13 VisuallyHidden,
14 Container,
15 Scroller,
16} from '@mantine/core';
17import { useForm } from '@mantine/form';
18import { notifications } from '@mantine/notifications';
19import useAddCard from '../../lib/mutations/useAddCard';
20import CollectionSelector from '@/features/collections/components/collectionSelector/CollectionSelector';
21import { Suspense, useEffect, useRef, useState } from 'react';
22import CollectionSelectorSkeleton from '@/features/collections/components/collectionSelector/Skeleton.CollectionSelector';
23import { useDisclosure } from '@mantine/hooks';
24import { BiCollection } from 'react-icons/bi';
25import { IoMdCheckmark, IoMdLink } from 'react-icons/io';
26import { DEFAULT_OVERLAY_PROPS } from '@/styles/overlays';
27import { track } from '@vercel/analytics';
28import useMyCollections from '@/features/collections/lib/queries/useMyCollections';
29import { isMarginUri, getMarginUrl } from '@/lib/utils/margin';
30import MarginLogo from '@/components/MarginLogo';
31import { Collection, CollectionAccessType } from '@semble/types';
32import { FaSeedling } from 'react-icons/fa6';
33import { CardSaveSource } from '@/features/analytics/types';
34import { usePathname } from 'next/navigation';
35import UrlSearchInput from '@/features/connections/components/addConnectionDrawer/UrlSearchInput';
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 rawUrlInput = useRef(props.initialUrl ?? '');
71
72 const form = useForm({
73 initialValues: {
74 url: props.initialUrl || '',
75 note: '',
76 collections: selectedCollections,
77 },
78 validateInputOnChange: false,
79 validateInputOnBlur: true,
80 validate: {
81 url: (value) => {
82 if (!value || value.trim() === '') {
83 return 'URL is required';
84 }
85 try {
86 new URL(value);
87 return null;
88 } catch {
89 return 'Please enter a valid URL';
90 }
91 },
92 },
93 });
94
95 const MAX_NOTE_LENGTH = 500;
96
97 useEffect(() => {
98 if (props.initialUrl) {
99 form.setValues({ url: props.initialUrl });
100 rawUrlInput.current = props.initialUrl;
101 }
102 }, [props.initialUrl]);
103
104 const handleAddCard = (e: React.FormEvent) => {
105 e.preventDefault();
106
107 // Auto-confirm a valid URL that was typed/pasted but not explicitly selected
108 if (!form.values.url && rawUrlInput.current) {
109 try {
110 new URL(rawUrlInput.current);
111 form.setFieldValue('url', rawUrlInput.current);
112 } catch {
113 // let validation handle it
114 }
115 }
116
117 const validation = form.validate();
118 if (validation.hasErrors) return;
119
120 track('add new card');
121
122 // Capture values before any state changes
123 const cardData = {
124 url: form.getValues().url,
125 note: form.getValues().note,
126 collectionIds: selectedCollections.map((c) => c.id),
127 };
128
129 // Show loading toast immediately
130 const notificationId = `add-card-${Date.now()}`;
131 notifications.show({
132 id: notificationId,
133 loading: true,
134 title: 'Adding card...',
135 message: 'Please wait',
136 position: 'top-center',
137 autoClose: false,
138 withCloseButton: false,
139 });
140
141 // Close drawer immediately
142 props.onClose();
143 setSelectedCollections(initialCollections);
144 rawUrlInput.current = '';
145 form.reset();
146
147 addCard.mutate({ ...cardData, notificationId });
148 };
149
150 return (
151 <>
152 <form onSubmit={handleAddCard}>
153 <Stack gap={'xl'}>
154 <UrlSearchInput
155 id="url"
156 label="URL"
157 placeholder="Search or paste a link"
158 value={form.values.url}
159 error={form.errors.url}
160 onUrlSelect={(url) => form.setFieldValue('url', url)}
161 onUrlClear={() => {
162 rawUrlInput.current = '';
163 form.setFieldValue('url', '');
164 }}
165 onInputChange={(raw) => {
166 rawUrlInput.current = raw;
167 }}
168 inputProps={{
169 variant: 'filled',
170 size: 'md',
171 leftSection: <IoMdLink size={22} />,
172 'data-autofocus': true,
173 }}
174 />
175
176 <Stack gap={0}>
177 <Flex justify="space-between">
178 <Input.Label size="md" htmlFor="note">
179 Note
180 </Input.Label>
181 <Text c={'gray'} aria-hidden>
182 {form.getValues().note.length} / {MAX_NOTE_LENGTH}
183 </Text>
184 </Flex>
185
186 <Textarea
187 id="note"
188 placeholder="Add a note about this card"
189 variant="filled"
190 size="md"
191 rows={3}
192 maxLength={MAX_NOTE_LENGTH}
193 aria-describedby="note-char-remaining"
194 key={form.key('note')}
195 {...form.getInputProps('note')}
196 />
197 <VisuallyHidden id="note-char-remaining" aria-live="polite">
198 {`${MAX_NOTE_LENGTH - form.getValues().note.length} characters remaining`}
199 </VisuallyHidden>
200 </Stack>
201
202 <Stack gap={5}>
203 <Text fw={500}>
204 Add to collections{' '}
205 {selectedCollections.length > 0 &&
206 `(${selectedCollections.length})`}
207 </Text>
208 <Scroller>
209 <Group gap={'xs'} wrap="nowrap">
210 <Button
211 disabled={addCard.isPending}
212 onClick={toggleCollectionSelector}
213 variant="light"
214 color={'blue'}
215 leftSection={<BiCollection size={22} />}
216 >
217 {myCollections.length === 0
218 ? 'Create a collection'
219 : 'Manage & Create'}
220 </Button>
221
222 {myCollections.map((col) => {
223 const marginUrl = getMarginUrl(col.uri, col.author?.handle);
224 return (
225 <Button
226 key={col.id}
227 disabled={addCard.isPending}
228 variant="light"
229 color={
230 selectedCollections.some((c) => c.id === col.id)
231 ? 'grape'
232 : 'gray'
233 }
234 rightSection={
235 selectedCollections.some((c) => c.id === col.id) ? (
236 <IoMdCheckmark />
237 ) : null
238 }
239 leftSection={
240 isMarginUri(col.uri) ? (
241 <MarginLogo size={12} marginUrl={marginUrl} />
242 ) : col.accessType === CollectionAccessType.OPEN ? (
243 <ThemeIcon
244 variant="light"
245 radius={'xl'}
246 size={'xs'}
247 color="green"
248 >
249 <FaSeedling size={8} />
250 </ThemeIcon>
251 ) : undefined
252 }
253 onClick={() => {
254 setSelectedCollections((prev) => {
255 // already selected, remove
256 if (prev.some((c) => c.id === col.id)) {
257 return prev.filter((c) => c.id !== col.id);
258 }
259 // not selected, add it
260 return [...prev, col];
261 });
262 }}
263 >
264 {col.name}
265 </Button>
266 );
267 })}
268 </Group>
269 </Scroller>
270 </Stack>
271
272 <Group justify="space-between" gap={'xs'} grow>
273 <Button
274 variant="light"
275 size="md"
276 color={'gray'}
277 onClick={() => {
278 props.onClose();
279 setSelectedCollections(initialCollections);
280 }}
281 >
282 Cancel
283 </Button>
284 <Button type="submit" size="md" loading={addCard.isPending}>
285 Add card
286 </Button>
287 </Group>
288 </Stack>
289 </form>
290
291 <Drawer
292 opened={collectionSelectorOpened}
293 onClose={toggleCollectionSelector}
294 withCloseButton={false}
295 position="bottom"
296 padding={'sm'}
297 size={'30rem'}
298 overlayProps={DEFAULT_OVERLAY_PROPS}
299 >
300 <Drawer.Header>
301 <Drawer.Title fz={'xl'} fw={600} mx={'auto'}>
302 Add to collections
303 </Drawer.Title>
304 </Drawer.Header>
305 <Container size={'xs'} p={0}>
306 <Suspense fallback={<CollectionSelectorSkeleton />}>
307 <CollectionSelector
308 isOpen={collectionSelectorOpened}
309 onCancel={() => {
310 setSelectedCollections(initialCollections);
311 toggleCollectionSelector();
312 }}
313 onClose={toggleCollectionSelector}
314 onSave={toggleCollectionSelector}
315 selectedCollections={selectedCollections}
316 onSelectedCollectionsChange={setSelectedCollections}
317 />
318 </Suspense>
319 </Container>
320 </Drawer>
321 </>
322 );
323}