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