This repository has no description
0

Configure Feed

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

16 kB 491 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 [isInputFocused, setIsInputFocused] = useState(false); 96 const [debounced] = useDebouncedValue(inputValue, 200); 97 const [searchFilter, setSearchFilter] = useState<SearchFilter>('cards'); 98 99 const { 100 data: searchResults, 101 isFetching, 102 error, 103 } = useUrlSearch(searchFilter === 'cards' ? debounced : ''); 104 const { data: recentCards, isLoading: isLoadingRecentCards } = 105 useMyCardsInfinite({ limit: 5 }); 106 107 const collectionSearch = useSearchCollections({ 108 searchText: searchFilter === 'collections' ? debounced : '', 109 limit: 5, 110 enabled: searchFilter === 'collections' && debounced.trim().length > 0, 111 }); 112 113 const recentCardsList = recentCards?.pages[0]?.cards ?? []; 114 115 const urls = searchResults?.urls ?? []; 116 const collections = 117 collectionSearch.data?.pages.flatMap((page) => page.collections ?? []) ?? 118 []; 119 const isCollectionSearchFetching = collectionSearch.isFetching; 120 const collectionSearchError = collectionSearch.error; 121 122 // Sync when the external value changes (e.g. after swap) 123 const [prevValue, setPrevValue] = useState(props.value); 124 if (props.value !== prevValue) { 125 setInputValue(props.value); 126 setPrevValue(props.value); 127 if (isValidUrl(props.value)) { 128 setConfirmedUrl(props.value); 129 } else { 130 setConfirmedUrl(null); 131 } 132 } 133 134 const handleClear = () => { 135 setConfirmedUrl(null); 136 setInputValue(''); 137 props.onUrlSelect(''); 138 props.onUrlClear?.(); 139 }; 140 141 const buildCollectionUrl = (collection: { 142 uri?: string; 143 author: { handle: string }; 144 }): string | null => { 145 if (!collection.uri) return null; 146 const rkey = getRecordKey(collection.uri); 147 if (!rkey) return null; 148 const appUrl = process.env.NEXT_PUBLIC_APP_URL || window.location.origin; 149 return `${appUrl}/profile/${collection.author.handle}/collections/${rkey}`; 150 }; 151 152 if (confirmedUrl) { 153 return ( 154 <Fragment> 155 <SourceCardPreview sourceUrl={confirmedUrl} onRemove={handleClear} /> 156 <VisuallyHidden> 157 <Input.Label htmlFor={props.id} required> 158 {props.label} 159 </Input.Label> 160 </VisuallyHidden> 161 </Fragment> 162 ); 163 } 164 165 const isSearchFetching = 166 searchFilter === 'cards' ? isFetching : isCollectionSearchFetching; 167 168 const cardOptions = urls.map((urlView) => ( 169 <Combobox.Option key={urlView.url} value={urlView.url} p={5}> 170 <Group gap={'xs'} align="center" wrap="nowrap"> 171 {urlView.metadata.imageUrl && ( 172 <Image 173 src={urlView.metadata.imageUrl} 174 alt={urlView.metadata.title || 'URL thumbnail'} 175 w={35} 176 h={35} 177 radius="sm" 178 fit="cover" 179 /> 180 )} 181 <Stack gap={0}> 182 <Text fw={500} c={'bright'} lineClamp={1} size="sm"> 183 {urlView.metadata.title || urlView.url} 184 </Text> 185 <Text c={'gray'} lineClamp={1} size="xs"> 186 {getDomain(urlView.url)} 187 </Text> 188 </Stack> 189 </Group> 190 </Combobox.Option> 191 )); 192 193 const collectionOptions = collections.map((collection) => { 194 const collectionUrl = buildCollectionUrl(collection); 195 196 if (!collectionUrl) return null; 197 198 return ( 199 <Combobox.Option key={collection.id} value={collectionUrl} p={5}> 200 <Group gap={'xs'} align="center" justify="space-between" wrap="nowrap"> 201 <Text 202 fw={500} 203 c={ 204 collection.accessType === CollectionAccessType.OPEN 205 ? 'green' 206 : 'bright' 207 } 208 lineClamp={2} 209 size="sm" 210 > 211 {collection.name} 212 </Text> 213 214 <Avatar 215 src={collection.author?.avatarUrl?.replace( 216 'avatar', 217 'avatar_thumbnail', 218 )} 219 alt={`${collection.author?.handle}'s avatar`} 220 size={'sm'} 221 /> 222 </Group> 223 </Combobox.Option> 224 ); 225 }); 226 227 const hasSearchResults = 228 searchFilter === 'cards' 229 ? cardOptions.length > 0 230 : collectionOptions.filter(Boolean).length > 0; 231 232 const currentError = searchFilter === 'cards' ? error : collectionSearchError; 233 234 const isPlainLayout = !!props.inputProps; 235 236 const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { 237 const val = e.currentTarget.value; 238 setInputValue(val); 239 props.onInputChange?.(val); 240 combobox.openDropdown(); 241 }; 242 243 const handleFocus = () => { 244 setIsInputFocused(true); 245 combobox.openDropdown(); 246 }; 247 248 const handleBlur = () => { 249 setIsInputFocused(false); 250 }; 251 252 const inputElement = isPlainLayout ? ( 253 <TextInput 254 {...props.inputProps} 255 id={props.id} 256 label={props.label} 257 placeholder={props.placeholder} 258 value={inputValue} 259 onChange={handleInputChange} 260 onFocus={handleFocus} 261 onBlur={handleBlur} 262 error={props.error} 263 required 264 /> 265 ) : ( 266 <Input 267 id={props.id} 268 component="input" 269 type="text" 270 py={2.5} 271 placeholder={props.placeholder} 272 value={inputValue} 273 onChange={handleInputChange} 274 onFocus={handleFocus} 275 onBlur={handleBlur} 276 rightSection={null} 277 variant="unstyled" 278 size="md" 279 required 280 error={props.error} 281 /> 282 ); 283 284 const comboboxBlock = ( 285 <Combobox 286 shadow="sm" 287 radius={'md'} 288 store={combobox} 289 position="bottom-start" 290 onOptionSubmit={(url) => { 291 props.onUrlSelect(url); 292 setInputValue(url); 293 if (isValidUrl(url)) { 294 setConfirmedUrl(url); 295 // Look up metadata from search results or recent cards 296 const searchMatch = urls.find((u) => u.url === url); 297 const recentMatch = recentCardsList.find((c) => c.url === url); 298 } 299 combobox.closeDropdown(); 300 }} 301 > 302 <Combobox.Target>{inputElement}</Combobox.Target> 303 304 <Combobox.Dropdown 305 hidden={ 306 (inputValue.trim().length === 0 && 307 debounced.trim().length === 0 && 308 !( 309 isInputFocused && 310 (isLoadingRecentCards || recentCardsList.length > 0) 311 )) || 312 (inputValue.trim().length === 0 && debounced.trim().length > 0) 313 } 314 > 315 <Combobox.Options> 316 <ScrollArea.Autosize 317 type="scroll" 318 mah={{ base: 150, xs: 300 }} 319 offsetScrollbars={'present'} 320 > 321 {debounced.trim().length === 0 ? ( 322 <Fragment> 323 <Text size="sm" fw={500} c="dimmed" py="xs" px={5}> 324 Recent cards 325 </Text> 326 {isLoadingRecentCards ? ( 327 <Stack gap={5} p={5}> 328 {Array.from({ length: 5 }).map((_, i) => ( 329 <Group key={i} gap="xs" align="center" wrap="nowrap"> 330 <Skeleton height={35} width={35} radius="sm" /> 331 <Stack gap={4} style={{ flex: 1 }}> 332 <Skeleton height={12} width="70%" radius="xl" /> 333 <Skeleton height={10} width="40%" radius="xl" /> 334 </Stack> 335 </Group> 336 ))} 337 </Stack> 338 ) : ( 339 recentCardsList.map((card) => ( 340 <Combobox.Option key={card.url} value={card.url} p={5}> 341 <Group gap={'xs'} align="center" wrap="nowrap"> 342 {card.cardContent.imageUrl && ( 343 <Image 344 src={card.cardContent.imageUrl} 345 alt={card.cardContent.title || 'URL thumbnail'} 346 w={35} 347 h={35} 348 radius="sm" 349 fit="cover" 350 /> 351 )} 352 <Stack gap={0}> 353 <Text fw={500} c={'bright'} lineClamp={1} size="sm"> 354 {card.cardContent.title || card.url} 355 </Text> 356 <Text c={'gray'} lineClamp={1} size="xs"> 357 {getDomain(card.url)} 358 </Text> 359 </Stack> 360 </Group> 361 </Combobox.Option> 362 )) 363 )} 364 </Fragment> 365 ) : ( 366 <Fragment> 367 {currentError && ( 368 <Combobox.Empty>Could not search</Combobox.Empty> 369 )} 370 {!currentError && inputValue.trim() && ( 371 <Fragment> 372 <Combobox.Option value={inputValue}> 373 <Group gap="xs" wrap="nowrap" p={0}> 374 <ThemeIcon 375 radius={'xl'} 376 size={'sm'} 377 variant="light" 378 color="gray" 379 > 380 <BiPlus /> 381 </ThemeIcon> 382 <Stack gap={0} style={{ flex: 1 }}> 383 <Text size="sm" fw={600} c={'bright'}> 384 Add this link 385 </Text> 386 <Text size="xs" c="dimmed" lineClamp={1}> 387 {inputValue} 388 </Text> 389 </Stack> 390 </Group> 391 </Combobox.Option> 392 393 {(hasSearchResults || true) && ( 394 <Fragment> 395 <Text size="sm" fw={500} c="dimmed" py="xs" px={5}> 396 Search results 397 </Text> 398 <SegmentedControl 399 value={searchFilter} 400 onChange={(value) => { 401 setSearchFilter(value as SearchFilter); 402 combobox.resetSelectedOption(); 403 }} 404 data={[ 405 { label: 'Cards', value: 'cards' }, 406 { 407 label: 'Collections', 408 value: 'collections', 409 }, 410 ]} 411 size="xs" 412 mb="xs" 413 /> 414 </Fragment> 415 )} 416 </Fragment> 417 )} 418 {searchFilter === 'cards' && ( 419 <Fragment> 420 {isFetching ? ( 421 <Stack gap={5} p={5}> 422 {Array.from({ length: 3 }).map((_, i) => ( 423 <Group key={i} gap="xs" align="center" wrap="nowrap"> 424 <Skeleton height={35} width={35} radius="sm" /> 425 <Stack gap={4} style={{ flex: 1 }}> 426 <Skeleton height={12} width="70%" radius="xl" /> 427 <Skeleton height={10} width="40%" radius="xl" /> 428 </Stack> 429 </Group> 430 ))} 431 </Stack> 432 ) : cardOptions.length > 0 ? ( 433 <Fragment>{cardOptions}</Fragment> 434 ) : ( 435 debounced.trim().length > 0 && ( 436 <Combobox.Empty>No cards found</Combobox.Empty> 437 ) 438 )} 439 </Fragment> 440 )} 441 {searchFilter === 'collections' && ( 442 <Fragment> 443 {isCollectionSearchFetching ? ( 444 <Stack gap={5} p={5}> 445 {Array.from({ length: 5 }).map((_, i) => ( 446 <Group 447 key={i} 448 gap="xs" 449 align="center" 450 justify="space-between" 451 wrap="nowrap" 452 > 453 <Skeleton height={20} width="70%" radius="xl" /> 454 <Skeleton height={26} width={26} radius="md" /> 455 </Group> 456 ))} 457 </Stack> 458 ) : collectionOptions.filter(Boolean).length > 0 ? ( 459 <Fragment>{collectionOptions}</Fragment> 460 ) : ( 461 debounced.trim().length > 0 && ( 462 <Combobox.Empty>No collections found</Combobox.Empty> 463 ) 464 )} 465 </Fragment> 466 )} 467 </Fragment> 468 )} 469 </ScrollArea.Autosize> 470 </Combobox.Options> 471 </Combobox.Dropdown> 472 </Combobox> 473 ); 474 475 if (isPlainLayout) { 476 return comboboxBlock; 477 } 478 479 return ( 480 <Card padding="xs" radius="lg" withBorder> 481 <Stack gap={0}> 482 {comboboxBlock} 483 <VisuallyHidden> 484 <Input.Label htmlFor={props.id} required> 485 {props.label} 486 </Input.Label> 487 </VisuallyHidden> 488 </Stack> 489 </Card> 490 ); 491}