This repository has no description
0

Configure Feed

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

16 kB 499 lines
1'use client'; 2 3import { 4 Avatar, 5 Card, 6 Combobox, 7 Group, 8 Image, 9 Input, 10 Loader, 11 ScrollArea, 12 SegmentedControl, 13 Skeleton, 14 Stack, 15 Text, 16 TextInput, 17 type TextInputProps, 18 ThemeIcon, 19 useCombobox, 20 VisuallyHidden, 21} from '@mantine/core'; 22import { useDebouncedValue } from '@mantine/hooks'; 23import { Fragment, useState } from 'react'; 24import { useQuery } from '@tanstack/react-query'; 25import { searchUrls } from '../../lib/dal'; 26import { BiPlus } from 'react-icons/bi'; 27import { getDomain } from '@/lib/utils/link'; 28import { useMyCardsInfinite } from '@/features/cards/lib/queries/useMyCards'; 29import { CollectionAccessType, type UrlMetadata } from '@semble/types'; 30import SourceCardPreview from './SourceCardPreview'; 31import useSearchCollections from '@/features/collections/lib/queries/useSearchCollections'; 32import { getRecordKey } from '@/lib/utils/atproto'; 33 34type SearchFilter = 'cards' | 'collections'; 35 36function isValidUrl(value: string): boolean { 37 try { 38 new URL(value); 39 return true; 40 } catch { 41 return false; 42 } 43} 44 45function useUrlSearch(debounced: string) { 46 return useQuery({ 47 queryKey: ['url search', debounced], 48 queryFn: () => 49 searchUrls({ 50 searchQuery: debounced, 51 limit: 10, 52 }), 53 enabled: debounced.trim().length > 0, 54 }); 55} 56 57interface Props { 58 id: string; 59 label: string; 60 placeholder: string; 61 value: string; 62 error?: React.ReactNode; 63 onUrlSelect: (url: string) => void; 64 onUrlClear?: () => void; 65 onInputChange?: (rawValue: string) => void; 66 /** 67 * When provided, the input is rendered as a standard Mantine `TextInput` 68 * (with a visible label) and these props are spread onto it. Use this to 69 * customize variant, size, leftSection, etc. When omitted, the input keeps 70 * the legacy card-wrapped unstyled layout used by the connection form. 71 */ 72 inputProps?: Omit< 73 TextInputProps, 74 | 'id' 75 | 'label' 76 | 'placeholder' 77 | 'value' 78 | 'onChange' 79 | 'onFocus' 80 | 'onBlur' 81 | 'error' 82 | 'required' 83 > & { [K in `data-${string}`]?: unknown }; 84} 85 86export default function UrlSearchInput(props: Props) { 87 const combobox = useCombobox({ 88 onDropdownClose: () => combobox.resetSelectedOption(), 89 }); 90 91 const [inputValue, setInputValue] = useState(props.value); 92 const [confirmedUrl, setConfirmedUrl] = useState<string | null>( 93 isValidUrl(props.value) ? props.value : null, 94 ); 95 const [confirmedMetadata, setConfirmedMetadata] = useState< 96 UrlMetadata | undefined 97 >(undefined); 98 const [isInputFocused, setIsInputFocused] = useState(false); 99 const [debounced] = useDebouncedValue(inputValue, 200); 100 const [searchFilter, setSearchFilter] = useState<SearchFilter>('cards'); 101 102 const { 103 data: searchResults, 104 isFetching, 105 error, 106 } = useUrlSearch(searchFilter === 'cards' ? debounced : ''); 107 const { data: recentCards, isLoading: isLoadingRecentCards } = 108 useMyCardsInfinite({ limit: 5 }); 109 110 const collectionSearch = useSearchCollections({ 111 searchText: searchFilter === 'collections' ? debounced : '', 112 limit: 5, 113 enabled: searchFilter === 'collections' && debounced.trim().length > 0, 114 }); 115 116 const recentCardsList = recentCards?.pages[0]?.cards ?? []; 117 118 const urls = searchResults?.urls ?? []; 119 const collections = 120 collectionSearch.data?.pages.flatMap((page) => page.collections ?? []) ?? 121 []; 122 const isCollectionSearchFetching = collectionSearch.isFetching; 123 const collectionSearchError = collectionSearch.error; 124 125 // Sync when the external value changes (e.g. after swap) 126 const [prevValue, setPrevValue] = useState(props.value); 127 if (props.value !== prevValue) { 128 setInputValue(props.value); 129 setPrevValue(props.value); 130 if (isValidUrl(props.value)) { 131 setConfirmedUrl(props.value); 132 } else { 133 setConfirmedUrl(null); 134 } 135 setConfirmedMetadata(undefined); 136 } 137 138 const handleClear = () => { 139 setConfirmedUrl(null); 140 setConfirmedMetadata(undefined); 141 setInputValue(''); 142 props.onUrlSelect(''); 143 props.onUrlClear?.(); 144 }; 145 146 const buildCollectionUrl = (collection: { 147 uri?: string; 148 author: { handle: string }; 149 }): string | null => { 150 if (!collection.uri) return null; 151 const rkey = getRecordKey(collection.uri); 152 if (!rkey) return null; 153 const appUrl = process.env.NEXT_PUBLIC_APP_URL || window.location.origin; 154 return `${appUrl}/profile/${collection.author.handle}/collections/${rkey}`; 155 }; 156 157 if (confirmedUrl) { 158 return ( 159 <Fragment> 160 <SourceCardPreview sourceUrl={confirmedUrl} onRemove={handleClear} /> 161 <VisuallyHidden> 162 <Input.Label htmlFor={props.id} required> 163 {props.label} 164 </Input.Label> 165 </VisuallyHidden> 166 </Fragment> 167 ); 168 } 169 170 const isSearchFetching = 171 searchFilter === 'cards' ? isFetching : isCollectionSearchFetching; 172 173 const cardOptions = urls.map((urlView) => ( 174 <Combobox.Option key={urlView.url} value={urlView.url} p={5}> 175 <Group gap={'xs'} align="center" wrap="nowrap"> 176 {urlView.metadata.imageUrl && ( 177 <Image 178 src={urlView.metadata.imageUrl} 179 alt={urlView.metadata.title || 'URL thumbnail'} 180 w={35} 181 h={35} 182 radius="sm" 183 fit="cover" 184 /> 185 )} 186 <Stack gap={0}> 187 <Text fw={500} c={'bright'} lineClamp={1} size="sm"> 188 {urlView.metadata.title || urlView.url} 189 </Text> 190 <Text c={'gray'} lineClamp={1} size="xs"> 191 {getDomain(urlView.url)} 192 </Text> 193 </Stack> 194 </Group> 195 </Combobox.Option> 196 )); 197 198 const collectionOptions = collections.map((collection) => { 199 const collectionUrl = buildCollectionUrl(collection); 200 201 if (!collectionUrl) return null; 202 203 return ( 204 <Combobox.Option key={collection.id} value={collectionUrl} p={5}> 205 <Group gap={'xs'} align="center" justify="space-between" wrap="nowrap"> 206 <Text 207 fw={500} 208 c={ 209 collection.accessType === CollectionAccessType.OPEN 210 ? 'green' 211 : 'bright' 212 } 213 lineClamp={2} 214 size="sm" 215 > 216 {collection.name} 217 </Text> 218 219 <Avatar 220 src={collection.author?.avatarUrl?.replace( 221 'avatar', 222 'avatar_thumbnail', 223 )} 224 alt={`${collection.author?.handle}'s avatar`} 225 size={'sm'} 226 /> 227 </Group> 228 </Combobox.Option> 229 ); 230 }); 231 232 const hasSearchResults = 233 searchFilter === 'cards' 234 ? cardOptions.length > 0 235 : collectionOptions.filter(Boolean).length > 0; 236 237 const currentError = searchFilter === 'cards' ? error : collectionSearchError; 238 239 const isPlainLayout = !!props.inputProps; 240 241 const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { 242 const val = e.currentTarget.value; 243 setInputValue(val); 244 props.onInputChange?.(val); 245 combobox.openDropdown(); 246 }; 247 248 const handleFocus = () => { 249 setIsInputFocused(true); 250 combobox.openDropdown(); 251 }; 252 253 const handleBlur = () => { 254 setIsInputFocused(false); 255 }; 256 257 const inputElement = isPlainLayout ? ( 258 <TextInput 259 {...props.inputProps} 260 id={props.id} 261 label={props.label} 262 placeholder={props.placeholder} 263 value={inputValue} 264 onChange={handleInputChange} 265 onFocus={handleFocus} 266 onBlur={handleBlur} 267 error={props.error} 268 required 269 /> 270 ) : ( 271 <Input 272 id={props.id} 273 component="input" 274 type="text" 275 py={2.5} 276 placeholder={props.placeholder} 277 value={inputValue} 278 onChange={handleInputChange} 279 onFocus={handleFocus} 280 onBlur={handleBlur} 281 rightSection={null} 282 variant="unstyled" 283 size="md" 284 required 285 error={props.error} 286 /> 287 ); 288 289 const comboboxBlock = ( 290 <Combobox 291 shadow="sm" 292 radius={'md'} 293 store={combobox} 294 position="bottom-start" 295 onOptionSubmit={(url) => { 296 props.onUrlSelect(url); 297 setInputValue(url); 298 if (isValidUrl(url)) { 299 setConfirmedUrl(url); 300 // Look up metadata from search results or recent cards 301 const searchMatch = urls.find((u) => u.url === url); 302 const recentMatch = recentCardsList.find((c) => c.url === url); 303 setConfirmedMetadata( 304 searchMatch?.metadata ?? recentMatch?.cardContent, 305 ); 306 } 307 combobox.closeDropdown(); 308 }} 309 > 310 <Combobox.Target>{inputElement}</Combobox.Target> 311 312 <Combobox.Dropdown 313 hidden={ 314 (inputValue.trim().length === 0 && 315 debounced.trim().length === 0 && 316 !( 317 isInputFocused && 318 (isLoadingRecentCards || recentCardsList.length > 0) 319 )) || 320 (inputValue.trim().length === 0 && debounced.trim().length > 0) 321 } 322 > 323 <Combobox.Options> 324 <ScrollArea.Autosize 325 type="scroll" 326 mah={{ base: 150, xs: 300 }} 327 offsetScrollbars={'present'} 328 > 329 {debounced.trim().length === 0 ? ( 330 <Fragment> 331 <Text size="sm" fw={500} c="dimmed" py="xs" px={5}> 332 Recent cards 333 </Text> 334 {isLoadingRecentCards ? ( 335 <Stack gap={5} p={5}> 336 {Array.from({ length: 5 }).map((_, i) => ( 337 <Group key={i} gap="xs" align="center" wrap="nowrap"> 338 <Skeleton height={35} width={35} radius="sm" /> 339 <Stack gap={4} style={{ flex: 1 }}> 340 <Skeleton height={12} width="70%" radius="xl" /> 341 <Skeleton height={10} width="40%" radius="xl" /> 342 </Stack> 343 </Group> 344 ))} 345 </Stack> 346 ) : ( 347 recentCardsList.map((card) => ( 348 <Combobox.Option key={card.url} value={card.url} p={5}> 349 <Group gap={'xs'} align="center" wrap="nowrap"> 350 {card.cardContent.imageUrl && ( 351 <Image 352 src={card.cardContent.imageUrl} 353 alt={card.cardContent.title || 'URL thumbnail'} 354 w={35} 355 h={35} 356 radius="sm" 357 fit="cover" 358 /> 359 )} 360 <Stack gap={0}> 361 <Text fw={500} c={'bright'} lineClamp={1} size="sm"> 362 {card.cardContent.title || card.url} 363 </Text> 364 <Text c={'gray'} lineClamp={1} size="xs"> 365 {getDomain(card.url)} 366 </Text> 367 </Stack> 368 </Group> 369 </Combobox.Option> 370 )) 371 )} 372 </Fragment> 373 ) : ( 374 <Fragment> 375 {currentError && ( 376 <Combobox.Empty>Could not search</Combobox.Empty> 377 )} 378 {!currentError && inputValue.trim() && ( 379 <Fragment> 380 <Combobox.Option value={inputValue}> 381 <Group gap="xs" wrap="nowrap" p={0}> 382 <ThemeIcon 383 radius={'xl'} 384 size={'sm'} 385 variant="light" 386 color="gray" 387 > 388 <BiPlus /> 389 </ThemeIcon> 390 <Stack gap={0} style={{ flex: 1 }}> 391 <Text size="sm" fw={600} c={'bright'}> 392 Add this link 393 </Text> 394 <Text size="xs" c="dimmed" lineClamp={1}> 395 {inputValue} 396 </Text> 397 </Stack> 398 </Group> 399 </Combobox.Option> 400 401 {(hasSearchResults || true) && ( 402 <Fragment> 403 <Text size="sm" fw={500} c="dimmed" py="xs" px={5}> 404 Search results 405 </Text> 406 <SegmentedControl 407 value={searchFilter} 408 onChange={(value) => { 409 setSearchFilter(value as SearchFilter); 410 combobox.resetSelectedOption(); 411 }} 412 data={[ 413 { label: 'Cards', value: 'cards' }, 414 { 415 label: 'Collections', 416 value: 'collections', 417 }, 418 ]} 419 size="xs" 420 mb="xs" 421 /> 422 </Fragment> 423 )} 424 </Fragment> 425 )} 426 {searchFilter === 'cards' && ( 427 <Fragment> 428 {isFetching ? ( 429 <Stack gap={5} p={5}> 430 {Array.from({ length: 3 }).map((_, i) => ( 431 <Group key={i} gap="xs" align="center" wrap="nowrap"> 432 <Skeleton height={35} width={35} radius="sm" /> 433 <Stack gap={4} style={{ flex: 1 }}> 434 <Skeleton height={12} width="70%" radius="xl" /> 435 <Skeleton height={10} width="40%" radius="xl" /> 436 </Stack> 437 </Group> 438 ))} 439 </Stack> 440 ) : cardOptions.length > 0 ? ( 441 <Fragment>{cardOptions}</Fragment> 442 ) : ( 443 debounced.trim().length > 0 && ( 444 <Combobox.Empty>No cards found</Combobox.Empty> 445 ) 446 )} 447 </Fragment> 448 )} 449 {searchFilter === 'collections' && ( 450 <Fragment> 451 {isCollectionSearchFetching ? ( 452 <Stack gap={5} p={5}> 453 {Array.from({ length: 5 }).map((_, i) => ( 454 <Group 455 key={i} 456 gap="xs" 457 align="center" 458 justify="space-between" 459 wrap="nowrap" 460 > 461 <Skeleton height={20} width="70%" radius="xl" /> 462 <Skeleton height={26} width={26} radius="md" /> 463 </Group> 464 ))} 465 </Stack> 466 ) : collectionOptions.filter(Boolean).length > 0 ? ( 467 <Fragment>{collectionOptions}</Fragment> 468 ) : ( 469 debounced.trim().length > 0 && ( 470 <Combobox.Empty>No collections found</Combobox.Empty> 471 ) 472 )} 473 </Fragment> 474 )} 475 </Fragment> 476 )} 477 </ScrollArea.Autosize> 478 </Combobox.Options> 479 </Combobox.Dropdown> 480 </Combobox> 481 ); 482 483 if (isPlainLayout) { 484 return comboboxBlock; 485 } 486 487 return ( 488 <Card padding="xs" radius="lg" withBorder> 489 <Stack gap={0}> 490 {comboboxBlock} 491 <VisuallyHidden> 492 <Input.Label htmlFor={props.id} required> 493 {props.label} 494 </Input.Label> 495 </VisuallyHidden> 496 </Stack> 497 </Card> 498 ); 499}