This repository has no description
semble
/
src
/
webapp
/
features
/
connections
/
components
/
addConnectionDrawer
/
UrlSearchInput.tsx
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}