'use client'; import { Avatar, Card, Combobox, Group, Image, Input, Loader, ScrollArea, SegmentedControl, Skeleton, Stack, Text, TextInput, type TextInputProps, ThemeIcon, useCombobox, VisuallyHidden, } from '@mantine/core'; import { useDebouncedValue } from '@mantine/hooks'; import { Fragment, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { searchUrls } from '../../lib/dal'; import { BiPlus } from 'react-icons/bi'; import { getDomain } from '@/lib/utils/link'; import { useMyCardsInfinite } from '@/features/cards/lib/queries/useMyCards'; import { CollectionAccessType, type UrlMetadata } from '@semble/types'; import SourceCardPreview from './SourceCardPreview'; import useSearchCollections from '@/features/collections/lib/queries/useSearchCollections'; import { getRecordKey } from '@/lib/utils/atproto'; type SearchFilter = 'cards' | 'collections'; function isValidUrl(value: string): boolean { try { new URL(value); return true; } catch { return false; } } function useUrlSearch(debounced: string) { return useQuery({ queryKey: ['url search', debounced], queryFn: () => searchUrls({ searchQuery: debounced, limit: 10, }), enabled: debounced.trim().length > 0, }); } interface Props { id: string; label: string; placeholder: string; value: string; error?: React.ReactNode; onUrlSelect: (url: string) => void; onUrlClear?: () => void; onInputChange?: (rawValue: string) => void; /** * When provided, the input is rendered as a standard Mantine `TextInput` * (with a visible label) and these props are spread onto it. Use this to * customize variant, size, leftSection, etc. When omitted, the input keeps * the legacy card-wrapped unstyled layout used by the connection form. */ inputProps?: Omit< TextInputProps, | 'id' | 'label' | 'placeholder' | 'value' | 'onChange' | 'onFocus' | 'onBlur' | 'error' | 'required' > & { [K in `data-${string}`]?: unknown }; } export default function UrlSearchInput(props: Props) { const combobox = useCombobox({ onDropdownClose: () => combobox.resetSelectedOption(), }); const [inputValue, setInputValue] = useState(props.value); const [confirmedUrl, setConfirmedUrl] = useState( isValidUrl(props.value) ? props.value : null, ); const [isInputFocused, setIsInputFocused] = useState(false); const [debounced] = useDebouncedValue(inputValue, 200); const [searchFilter, setSearchFilter] = useState('cards'); const { data: searchResults, isFetching, error, } = useUrlSearch(searchFilter === 'cards' ? debounced : ''); const { data: recentCards, isLoading: isLoadingRecentCards } = useMyCardsInfinite({ limit: 5 }); const collectionSearch = useSearchCollections({ searchText: searchFilter === 'collections' ? debounced : '', limit: 5, enabled: searchFilter === 'collections' && debounced.trim().length > 0, }); const recentCardsList = recentCards?.pages[0]?.cards ?? []; const urls = searchResults?.urls ?? []; const collections = collectionSearch.data?.pages.flatMap((page) => page.collections ?? []) ?? []; const isCollectionSearchFetching = collectionSearch.isFetching; const collectionSearchError = collectionSearch.error; // Sync when the external value changes (e.g. after swap) const [prevValue, setPrevValue] = useState(props.value); if (props.value !== prevValue) { setInputValue(props.value); setPrevValue(props.value); if (isValidUrl(props.value)) { setConfirmedUrl(props.value); } else { setConfirmedUrl(null); } } const handleClear = () => { setConfirmedUrl(null); setInputValue(''); props.onUrlSelect(''); props.onUrlClear?.(); }; const buildCollectionUrl = (collection: { uri?: string; author: { handle: string }; }): string | null => { if (!collection.uri) return null; const rkey = getRecordKey(collection.uri); if (!rkey) return null; const appUrl = process.env.NEXT_PUBLIC_APP_URL || window.location.origin; return `${appUrl}/profile/${collection.author.handle}/collections/${rkey}`; }; if (confirmedUrl) { return ( {props.label} ); } const isSearchFetching = searchFilter === 'cards' ? isFetching : isCollectionSearchFetching; const cardOptions = urls.map((urlView) => ( {urlView.metadata.imageUrl && ( {urlView.metadata.title )} {urlView.metadata.title || urlView.url} {getDomain(urlView.url)} )); const collectionOptions = collections.map((collection) => { const collectionUrl = buildCollectionUrl(collection); if (!collectionUrl) return null; return ( {collection.name} ); }); const hasSearchResults = searchFilter === 'cards' ? cardOptions.length > 0 : collectionOptions.filter(Boolean).length > 0; const currentError = searchFilter === 'cards' ? error : collectionSearchError; const isPlainLayout = !!props.inputProps; const handleInputChange = (e: React.ChangeEvent) => { const val = e.currentTarget.value; setInputValue(val); props.onInputChange?.(val); combobox.openDropdown(); }; const handleFocus = () => { setIsInputFocused(true); combobox.openDropdown(); }; const handleBlur = () => { setIsInputFocused(false); }; const inputElement = isPlainLayout ? ( ) : ( ); const comboboxBlock = ( { props.onUrlSelect(url); setInputValue(url); if (isValidUrl(url)) { setConfirmedUrl(url); // Look up metadata from search results or recent cards const searchMatch = urls.find((u) => u.url === url); const recentMatch = recentCardsList.find((c) => c.url === url); } combobox.closeDropdown(); }} > {inputElement} ); if (isPlainLayout) { return comboboxBlock; } return ( {comboboxBlock} {props.label} ); }