This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

semble / src / webapp / features / cards / components / addCardDrawer / AddCardForm.tsx
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}