diff --git a/src/webapp/features/search/components/searchFilters/SearchFilters.tsx b/src/webapp/features/search/components/searchFilters/SearchFilters.tsx new file mode 100644 index 00000000..3dcf0529 --- /dev/null +++ b/src/webapp/features/search/components/searchFilters/SearchFilters.tsx @@ -0,0 +1,337 @@ +'use client'; + +import { + Drawer, + TextInput, + Loader, + Group, + Avatar, + Stack, + Text, + ScrollArea, + ActionIcon, + Button, + CloseButton, + Box, + Indicator, + Container, + Combobox, + useCombobox, +} from '@mantine/core'; +import { MdOutlineAlternateEmail } from 'react-icons/md'; +import { TbAdjustmentsHorizontal } from 'react-icons/tb'; +import { useDebouncedValue, upperFirst } from '@mantine/hooks'; +import { useQuery } from '@tanstack/react-query'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { createContext, useContext, useState, ReactNode } from 'react'; +import { searchBlueskyUsers } from '@/features/platforms/bluesky/lib/dal'; +import { UPDATE_OVERLAY_PROPS } from '@/styles/overlays'; +import { UrlType } from '@semble/types'; +import { getUrlTypeIcon } from '@/lib/utils/icon'; + +// context +interface FilterContextValue { + opened: boolean; + setOpened: (val: boolean) => void; + searchQuery: string; + setSearchQuery: (val: string) => void; + selectedHandle: string; + setSelectedHandle: (val: string) => void; + localType: UrlType | null; + setLocalType: (val: UrlType | null) => void; + appliedHandle: string; + appliedType: UrlType | null; +} + +const FilterContext = createContext(null); + +const useFilterContext = () => { + const ctx = useContext(FilterContext); + if (!ctx) + throw new Error( + 'SearchFilter components must be wrapped in SearchFilter.Root', + ); + return ctx; +}; + +// root +export function Root(props: { children: ReactNode }) { + const [opened, setOpened] = useState(false); + const searchParams = useSearchParams(); + + const appliedHandle = searchParams.get('handle') ?? ''; + const appliedType = searchParams.get('type') as UrlType | null; + + const [searchQuery, setSearchQuery] = useState(appliedHandle); + const [selectedHandle, setSelectedHandle] = useState(appliedHandle); + const [localType, setLocalType] = useState(appliedType); + + const handleOpen = () => { + setSearchQuery(appliedHandle); + setSelectedHandle(appliedHandle); + setLocalType(appliedType); + setOpened(true); + }; + + const hasFilters = !!appliedHandle || !!appliedType; + + return ( + + + + + + + + setOpened(false)} + position="bottom" + padding="md" + size="sm" + withCloseButton={false} + overlayProps={UPDATE_OVERLAY_PROPS} + trapFocus={false} + > + + + Filters + + + + {props.children} + + + + ); +} + +// profile filter +export function ProfileFilter() { + const ctx = useFilterContext(); + const combobox = useCombobox({ + onDropdownClose: () => combobox.resetSelectedOption(), + }); + + const [debounced] = useDebouncedValue(ctx.searchQuery, 200); + + const { + data: actors = [], + isFetching, + error, + } = useQuery({ + queryKey: ['bluesky user search', debounced], + queryFn: () => searchBlueskyUsers(debounced), + enabled: debounced.trim().length > 0, + }); + + const options = actors.map((user) => ( + + + + + + {user.displayName || user.handle} + + + @{user.handle} + + + + + )); + + return ( + { + ctx.setSelectedHandle(val); + ctx.setSearchQuery(val); + combobox.closeDropdown(); + }} + position="bottom" + middlewares={{ flip: false, shift: true }} + > + + { + ctx.setSearchQuery(e.currentTarget.value); + if (ctx.selectedHandle) ctx.setSelectedHandle(''); + combobox.openDropdown(); + }} + onFocus={() => combobox.openDropdown()} + onBlur={() => { + combobox.closeDropdown(); + ctx.setSearchQuery(ctx.selectedHandle); + }} + leftSection={} + rightSection={ + isFetching ? ( + + ) : ( + ctx.searchQuery && ( + { + ctx.setSearchQuery(''); + ctx.setSelectedHandle(''); + }} + /> + ) + ) + } + /> + + + + + ); +} + +// url type filter +export function UrlTypeFilter() { + const ctx = useFilterContext(); + + return ( + + + By content type + + + + + {Object.values(UrlType).map((type) => { + const Icon = getUrlTypeIcon(type); + return ( + + ); + })} + + + + ); +} + +// actions +export function Actions() { + const ctx = useFilterContext(); + const router = useRouter(); + const searchParams = useSearchParams(); + + const hasAnyActiveFilters = + !!ctx.appliedHandle || + !!ctx.appliedType || + !!ctx.selectedHandle || + ctx.localType !== null; + + const handleApply = () => { + const params = new URLSearchParams(searchParams.toString()); + + if (ctx.selectedHandle) params.set('handle', ctx.selectedHandle); + else params.delete('handle'); + + if (ctx.localType) params.set('type', ctx.localType); + else params.delete('type'); + + router.replace(`?${params.toString()}`, { scroll: false }); + ctx.setOpened(false); + }; + + const handleClear = () => { + const params = new URLSearchParams(searchParams.toString()); + params.delete('handle'); + params.delete('type'); + + ctx.setSearchQuery(''); + ctx.setSelectedHandle(''); + ctx.setLocalType(null); + + router.replace(`?${params.toString()}`, { scroll: false }); + }; + + const isInvalid = ctx.searchQuery !== ctx.selectedHandle; + + return ( + + + + + {hasAnyActiveFilters && ( + + )} + + + + + ); +} + +export const SearchFilters = { Root, ProfileFilter, UrlTypeFilter, Actions }; diff --git a/src/webapp/features/search/components/searchQueryAlert/SearchQueryAlert.tsx b/src/webapp/features/search/components/searchQueryAlert/SearchQueryAlert.tsx index 19196a35..69e7fab3 100644 --- a/src/webapp/features/search/components/searchQueryAlert/SearchQueryAlert.tsx +++ b/src/webapp/features/search/components/searchQueryAlert/SearchQueryAlert.tsx @@ -11,20 +11,13 @@ export default function SearchQueryAlert(props: Props) { p={'xs'} radius={'lg'} color="gray" + w={'100%'} title={ - + Showing results for{' '} "{props.query}" - {props.handle && ( - <> - {` by`}{' '} - - @{props.handle} - - - )} } /> diff --git a/src/webapp/features/search/components/usernameSearch/UsernameSearch.tsx b/src/webapp/features/search/components/usernameSearch/UsernameSearch.tsx deleted file mode 100644 index 70b76284..00000000 --- a/src/webapp/features/search/components/usernameSearch/UsernameSearch.tsx +++ /dev/null @@ -1,186 +0,0 @@ -'use client'; - -import { - Drawer, - TextInput, - Loader, - Group, - Avatar, - Stack, - Text, - ScrollArea, - ActionIcon, - Button, - CloseButton, - UnstyledButton, - Box, - Indicator, -} from '@mantine/core'; -import { MdOutlineAlternateEmail } from 'react-icons/md'; -import { TbAdjustmentsHorizontal } from 'react-icons/tb'; -import { useDebouncedValue } from '@mantine/hooks'; -import { useQuery } from '@tanstack/react-query'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { useState } from 'react'; -import { searchBlueskyUsers } from '@/features/platforms/bluesky/lib/dal'; - -export default function UsernameSearch() { - const router = useRouter(); - const searchParams = useSearchParams(); - - const initialHandle = searchParams.get('handle') ?? ''; - const [opened, setOpened] = useState(false); - const [inputValue, setInputValue] = useState(initialHandle); - const [appliedHandle, setAppliedHandle] = useState(initialHandle); - - const [debounced] = useDebouncedValue(inputValue, 200); - - const { - data: actors = [], - isFetching, - error, - } = useQuery({ - queryKey: ['bluesky user search', debounced], - queryFn: () => searchBlueskyUsers(debounced), - enabled: debounced.trim().length > 0, - }); - - const updateHandleParam = (handle: string) => { - const params = new URLSearchParams(searchParams.toString()); - if (handle) { - params.set('handle', handle); - } else { - params.delete('handle'); - } - router.replace(`?${params.toString()}`, { scroll: false }); - setAppliedHandle(handle); - }; - - const handleSelect = (handle: string) => { - setInputValue(handle); - updateHandleParam(handle); - setOpened(false); - }; - - const clearFilters = () => { - setInputValue(''); - updateHandleParam(''); - setOpened(false); - }; - - return ( - <> - - setOpened(true)} - > - - - - - setOpened(false)} - position="bottom" - padding="md" - size="sm" - withCloseButton={false} - > - - - Filters - - - - - - setInputValue(e.currentTarget.value)} - leftSection={} - rightSection={ - isFetching ? ( - - ) : ( - inputValue && ( - setInputValue('')} - /> - ) - ) - } - /> - - {(debounced.trim().length > 0 || isFetching) && ( - - - {isFetching && ( - - Searching… - - )} - - {!isFetching && actors.length === 0 && ( - - No profiles found - - )} - - {error && ( - - Could not search profiles - - )} - - {actors.map((user) => ( - handleSelect(user.handle)} - component={Box} // Allows for the sx-like hover styling - p={5} - style={(theme) => ({ - borderRadius: theme.radius.sm, - transition: 'background-color 100ms ease', - '&:hover': { - backgroundColor: theme.colors.gray[0], - }, - })} - > - - - - - {user.displayName || user.handle} - - - @{user.handle} - - - - - ))} - - - )} - - - - ); -} diff --git a/src/webapp/features/search/containers/cardSearchResultsContainer/CardSearchResultsContainer.tsx b/src/webapp/features/search/containers/cardSearchResultsContainer/CardSearchResultsContainer.tsx index c3452d92..fb309373 100644 --- a/src/webapp/features/search/containers/cardSearchResultsContainer/CardSearchResultsContainer.tsx +++ b/src/webapp/features/search/containers/cardSearchResultsContainer/CardSearchResultsContainer.tsx @@ -9,7 +9,7 @@ import { Grid, Group } from '@mantine/core'; import SimilarUrlCard from '@/features/semble/components/similarUrlCard/SimilarUrlCard'; import SearchResultsContainerError from '../searchResultsContainer/Error.SearchResultsContainer'; import SearchQueryAlert from '../../components/searchQueryAlert/SearchQueryAlert'; -import UsernameSearch from '../../components/usernameSearch/UsernameSearch'; +import { SearchFilters } from '../../components/searchFilters/SearchFilters'; interface Props { query: string; @@ -46,8 +46,11 @@ export default function CardSearchResultsContainer(props: Props) { > - - + + + + + {allUrls.map((urlView) => ( diff --git a/src/webapp/features/search/containers/collectionSearchResultsContainer/CollectionSearchResultsContainer.tsx b/src/webapp/features/search/containers/collectionSearchResultsContainer/CollectionSearchResultsContainer.tsx index f8bbfe2a..bd87d5ad 100644 --- a/src/webapp/features/search/containers/collectionSearchResultsContainer/CollectionSearchResultsContainer.tsx +++ b/src/webapp/features/search/containers/collectionSearchResultsContainer/CollectionSearchResultsContainer.tsx @@ -6,6 +6,7 @@ import { Grid } from '@mantine/core'; import SearchResultsContainerError from '../searchResultsContainer/Error.SearchResultsContainer'; import useSearchCollections from '@/features/collections/lib/queries/useSearchCollections'; import CollectionCard from '@/features/collections/components/collectionCard/CollectionCard'; +import SearchQueryAlert from '../../components/searchQueryAlert/SearchQueryAlert'; interface Props { query: string; @@ -40,6 +41,7 @@ export default function CollectionSearchResultsContainer(props: Props) { isLoading={isFetchingNextPage} loadMore={fetchNextPage} > + {allCollections.map((collection) => ( diff --git a/src/webapp/features/search/containers/profileSearchResultsContainer/ProfileSearchResultsContainer.tsx b/src/webapp/features/search/containers/profileSearchResultsContainer/ProfileSearchResultsContainer.tsx index 265da2fb..a0c5c07e 100644 --- a/src/webapp/features/search/containers/profileSearchResultsContainer/ProfileSearchResultsContainer.tsx +++ b/src/webapp/features/search/containers/profileSearchResultsContainer/ProfileSearchResultsContainer.tsx @@ -6,6 +6,7 @@ import { Grid } from '@mantine/core'; import ProfileSearchResultsContainerError from './Error.ProfileSearchResultsContainer'; import ProfileCard from '../../components/profileCard/ProfileCard'; import SearchEmptyResults from '../../components/searchEmptyResults/SearchEmptyResults'; +import SearchQueryAlert from '../../components/searchQueryAlert/SearchQueryAlert'; interface Props { query: string; @@ -39,6 +40,7 @@ export default function ProfileSearchResultsContainer(props: Props) { isLoading={isFetchingNextPage} loadMore={fetchNextPage} > + {allProfiles.map((profile) => ( diff --git a/src/webapp/features/search/containers/searchResultsContainer/SearchResultsContainer.tsx b/src/webapp/features/search/containers/searchResultsContainer/SearchResultsContainer.tsx index 778904e3..f648a774 100644 --- a/src/webapp/features/search/containers/searchResultsContainer/SearchResultsContainer.tsx +++ b/src/webapp/features/search/containers/searchResultsContainer/SearchResultsContainer.tsx @@ -15,13 +15,11 @@ import { BiCollection } from 'react-icons/bi'; import { FaRegNoteSticky } from 'react-icons/fa6'; import { MdOutlinePeopleAlt } from 'react-icons/md'; import CollectionSearchResultsContainer from '../collectionSearchResultsContainer/CollectionSearchResultsContainer'; -import { Suspense } from 'react'; import CollectionSearchResultsContainerSkeleton from '../collectionSearchResultsContainer/Skeleton.CollectionSearchResultsContainer'; import CardSearchResultsContainerSkeleton from '../cardSearchResultsContainer/Skeleton.CardSearchresultsContainerSkeleton'; import ProfileSearchResultsContainerSkeleton from '../profileSearchResultsContainer/Skeleton.ProfileSearchResultsContainer'; import SearchTabItem from '../../components/searchTabItem/SearchTabItem'; -import SearchQueryAlert from '../../components/searchQueryAlert/SearchQueryAlert'; -import UsernameSearch from '../../components/usernameSearch/UsernameSearch'; +import { Suspense } from 'react'; interface Props { query: string; @@ -89,10 +87,7 @@ export default function SearchResultsContainer(props: Props) { fallback={} key={props.query} > - - - - + @@ -102,10 +97,7 @@ export default function SearchResultsContainer(props: Props) { fallback={} key={props.query} > - - - - +