This repository has no description
0

Configure Feed

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

Merge branch 'development' of https://github.com/cosmik-network/semble into development

+568 -6
+7
src/webapp/app/(dashboard)/search/error.tsx
··· 1 + 'use client'; 2 + 3 + import SearchContainerError from '@/features/search/containers/searchContainer/Error.SearchContainer'; 4 + 5 + export default function Error() { 6 + return <SearchContainerError />; 7 + }
+24
src/webapp/app/(dashboard)/search/layout.tsx
··· 1 + import BackButton from '@/components/navigation/backButton/BackButton'; 2 + import Header from '@/components/navigation/header/Header'; 3 + import type { Metadata } from 'next'; 4 + import { Fragment } from 'react'; 5 + 6 + export const metadata: Metadata = { 7 + title: 'Search', 8 + description: 'Search for cards', 9 + }; 10 + 11 + interface Props { 12 + children: React.ReactNode; 13 + } 14 + 15 + export default function Layout(props: Props) { 16 + return ( 17 + <Fragment> 18 + <Header title="Search"> 19 + <BackButton href="/home">Home</BackButton> 20 + </Header> 21 + {props.children} 22 + </Fragment> 23 + ); 24 + }
+5
src/webapp/app/(dashboard)/search/loading.tsx
··· 1 + import SearchContainerSkeleton from '@/features/search/containers/searchContainer/Skeleton.SearchContainer'; 2 + 3 + export default function Loading() { 4 + return <SearchContainerSkeleton />; 5 + }
+5
src/webapp/app/(dashboard)/search/page.tsx
··· 1 + import SearchContainer from '@/features/search/containers/searchContainer/SearchContainer'; 2 + 3 + export default async function Page() { 4 + return <SearchContainer />; 5 + }
+16
src/webapp/app/api/feature-flags/route.ts
··· 1 + import { getServerFeatureFlags } from '@/lib/serverFeatureFlags'; 2 + import { NextResponse } from 'next/server'; 3 + 4 + export async function GET() { 5 + try { 6 + const featureFlags = await getServerFeatureFlags(); 7 + return NextResponse.json(featureFlags); 8 + } catch (error) { 9 + console.error('Error fetching feature flags:', error); 10 + // Return default flags if there's an error 11 + return NextResponse.json({ 12 + similarCards: false, 13 + cardSearch: false, 14 + }); 15 + } 16 + }
+7
src/webapp/app/search/error.tsx
··· 1 + 'use client'; 2 + 3 + import SearchContainerError from '@/features/search/containers/searchContainer/Error.SearchContainer'; 4 + 5 + export default function Error() { 6 + return <SearchContainerError />; 7 + }
+24
src/webapp/app/search/layout.tsx
··· 1 + import BackButton from '@/components/navigation/backButton/BackButton'; 2 + import Header from '@/components/navigation/header/Header'; 3 + import type { Metadata } from 'next'; 4 + import { Fragment } from 'react'; 5 + 6 + export const metadata: Metadata = { 7 + title: 'Search', 8 + description: 'Search for cards', 9 + }; 10 + 11 + interface Props { 12 + children: React.ReactNode; 13 + } 14 + 15 + export default function Layout(props: Props) { 16 + return ( 17 + <Fragment> 18 + <Header title="Search"> 19 + <BackButton href="/">Home</BackButton> 20 + </Header> 21 + {props.children} 22 + </Fragment> 23 + ); 24 + }
+5
src/webapp/app/search/loading.tsx
··· 1 + import SearchContainerSkeleton from '@/features/search/containers/searchContainer/Skeleton.SearchContainer'; 2 + 3 + export default function Loading() { 4 + return <SearchContainerSkeleton />; 5 + }
+6
src/webapp/components/navigation/bottomBar/BottomBar.tsx
··· 2 2 import { FaRegNoteSticky } from 'react-icons/fa6'; 3 3 import { LuLibrary } from 'react-icons/lu'; 4 4 import { MdOutlineEmojiNature } from 'react-icons/md'; 5 + import { BiSearch } from 'react-icons/bi'; 5 6 import BottomBarItem from '../bottomBarItem/BottomBarItem'; 6 7 import useMyProfile from '@/features/profile/lib/queries/useMyProfile'; 7 8 import { RiNotification2Line } from 'react-icons/ri'; 8 9 import useUnreadNotificationCount from '@/features/notifications/lib/queries/useUnreadNotificationCount'; 10 + import { useFeatureFlags } from '@/lib/clientFeatureFlags'; 9 11 10 12 export default function BottomBar() { 11 13 const { data: profile } = useMyProfile(); 12 14 const { data: notificationData } = useUnreadNotificationCount(); 15 + const { data: featureFlags } = useFeatureFlags(); 13 16 14 17 return ( 15 18 <AppShellFooter px={'sm'} pb={'lg'} py={'xs'} hiddenFrom="sm"> ··· 25 28 title="Cards" 26 29 icon={FaRegNoteSticky} 27 30 /> 31 + {featureFlags?.cardSearch && ( 32 + <BottomBarItem href="/search" title="Search" icon={BiSearch} /> 33 + )} 28 34 29 35 <BottomBarItem 30 36 href="/notifications"
+18 -6
src/webapp/components/navigation/guestNavbar/GuestNavbar.tsx
··· 16 16 import Link from 'next/link'; 17 17 import SembleLogo from '@/assets/semble-logo.svg'; 18 18 import NavbarToggle from '../NavbarToggle'; 19 - import { BiRightArrowAlt } from 'react-icons/bi'; 19 + import { BiRightArrowAlt, BiSearch } from 'react-icons/bi'; 20 + import { useFeatureFlags } from '@/lib/clientFeatureFlags'; 20 21 21 22 export default function GuestNavbar() { 23 + const { data: featureFlags } = useFeatureFlags(); 22 24 return ( 23 25 <AppShellNavbar p={'xs'} style={{ zIndex: 3 }}> 24 26 <Group justify="space-between"> ··· 52 54 Log in 53 55 </Button> 54 56 </Group> 55 - <NavItem 56 - href="/explore" 57 - label="Explore" 58 - icon={<MdOutlineEmojiNature size={25} />} 59 - /> 57 + 58 + <Stack gap={5}> 59 + <NavItem 60 + href="/explore" 61 + label="Explore" 62 + icon={<MdOutlineEmojiNature size={25} />} 63 + /> 64 + {featureFlags?.cardSearch && ( 65 + <NavItem 66 + href="/search" 67 + label="Search" 68 + icon={<BiSearch size={25} />} 69 + /> 70 + )} 71 + </Stack> 60 72 </Stack> 61 73 </Stack> 62 74 </AppShellSection>
+10
src/webapp/components/navigation/navbar/Navbar.tsx
··· 18 18 import { MdOutlineEmojiNature } from 'react-icons/md'; 19 19 import { FaRegNoteSticky } from 'react-icons/fa6'; 20 20 import { TbSettings } from 'react-icons/tb'; 21 + import { BiSearch } from 'react-icons/bi'; 21 22 import Link from 'next/link'; 22 23 import SembleLogo from '@/assets/semble-logo.svg'; 23 24 import ProfileMenu from '@/features/profile/components/profileMenu/ProfileMenu'; ··· 29 30 import useMyProfile from '@/features/profile/lib/queries/useMyProfile'; 30 31 import { track } from '@vercel/analytics'; 31 32 import NotificationNavItem from '@/features/notifications/components/notificationNavItem/NotificationNavItem'; 33 + import { useFeatureFlags } from '@/lib/clientFeatureFlags'; 32 34 33 35 export default function Navbar() { 34 36 const [openAddDrawer, setOpenAddDrawer] = useState(false); 35 37 const { data: profile } = useMyProfile(); 38 + const { data: featureFlags } = useFeatureFlags(); 36 39 37 40 return ( 38 41 <AppShellNavbar p={'xs'} style={{ zIndex: 3 }}> ··· 61 64 label="Explore" 62 65 icon={<MdOutlineEmojiNature size={25} />} 63 66 /> 67 + {featureFlags?.cardSearch && ( 68 + <NavItem 69 + href="/search" 70 + label="Search" 71 + icon={<BiSearch size={25} />} 72 + /> 73 + )} 64 74 <NotificationNavItem /> 65 75 66 76 <NavItem
+22
src/webapp/features/search/components/searchEmptyResults/SearchEmptyResults.tsx
··· 1 + import { Stack, Text, Center } from '@mantine/core'; 2 + import { BiSearch } from 'react-icons/bi'; 3 + 4 + interface Props { 5 + query: string; 6 + } 7 + 8 + export default function SearchEmptyResults(props: Props) { 9 + return ( 10 + <Center py="xl"> 11 + <Stack align="center" gap="sm"> 12 + <BiSearch size={48} opacity={0.5} /> 13 + <Text size="lg" fw={500} c="dimmed"> 14 + No results found 15 + </Text> 16 + <Text size="sm" c="dimmed" ta="center"> 17 + No cards found for "{props.query}". Try a different search term. 18 + </Text> 19 + </Stack> 20 + </Center> 21 + ); 22 + }
+150
src/webapp/features/search/components/userFilterCombobox/UserFilterCombobox.tsx
··· 1 + 'use client'; 2 + 3 + import { 4 + Combobox, 5 + TextInput, 6 + Loader, 7 + Group, 8 + Avatar, 9 + Stack, 10 + Text, 11 + ScrollArea, 12 + Button, 13 + } from '@mantine/core'; 14 + import { useCombobox } from '@mantine/core'; 15 + import { useState } from 'react'; 16 + import { useDebouncedValue } from '@mantine/hooks'; 17 + import { useQuery } from '@tanstack/react-query'; 18 + import { searchBlueskyUsers } from '@/features/platforms/bluesky/lib/dal'; 19 + import { MdOutlinePersonSearch, MdClear } from 'react-icons/md'; 20 + import type { ProfileViewBasic } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; 21 + 22 + interface Props { 23 + selectedUser: ProfileViewBasic | null; 24 + onUserSelect: (user: ProfileViewBasic | null) => void; 25 + } 26 + 27 + export default function UserFilterCombobox(props: Props) { 28 + const combobox = useCombobox({ 29 + onDropdownClose: () => combobox.resetSelectedOption(), 30 + }); 31 + 32 + const [inputValue, setInputValue] = useState(''); 33 + const [debounced] = useDebouncedValue(inputValue, 200); 34 + 35 + const { 36 + data: actors = [], 37 + isFetching, 38 + error, 39 + } = useQuery({ 40 + queryKey: ['bluesky user search filter', debounced], 41 + queryFn: () => searchBlueskyUsers(debounced), 42 + enabled: debounced.trim().length > 0, 43 + }); 44 + 45 + const suggestions = actors; 46 + const empty = 47 + !error && 48 + !isFetching && 49 + debounced.trim().length > 0 && 50 + suggestions.length === 0; 51 + 52 + const options = suggestions.map((user) => ( 53 + <Combobox.Option key={user.did} value={user.handle} p={5}> 54 + <Group gap={'xs'} wrap="nowrap"> 55 + <Avatar 56 + src={user.avatar?.replace('avatar', 'avatar_thumbnail')} 57 + alt={`${user.handle}'s avatar`} 58 + /> 59 + <Stack gap={0}> 60 + <Text fw={500} c={'bright'} lineClamp={1}> 61 + {user.displayName || user.handle} 62 + </Text> 63 + <Text fw={500} c={'gray'} lineClamp={1}> 64 + @{user.handle} 65 + </Text> 66 + </Stack> 67 + </Group> 68 + </Combobox.Option> 69 + )); 70 + 71 + const handleClear = () => { 72 + props.onUserSelect(null); 73 + setInputValue(''); 74 + combobox.closeDropdown(); 75 + }; 76 + 77 + const displayValue = props.selectedUser 78 + ? `@${props.selectedUser.handle}` 79 + : inputValue; 80 + 81 + return ( 82 + <Group gap="xs"> 83 + <Combobox 84 + shadow="sm" 85 + radius={'md'} 86 + store={combobox} 87 + withinPortal={false} 88 + onOptionSubmit={(handleValue) => { 89 + const selectedActor = actors.find( 90 + (actor) => actor.handle === handleValue, 91 + ); 92 + if (selectedActor) { 93 + props.onUserSelect(selectedActor); 94 + setInputValue(''); 95 + combobox.closeDropdown(); 96 + } 97 + }} 98 + > 99 + <Combobox.Target> 100 + <TextInput 101 + placeholder="Filter by user..." 102 + value={displayValue} 103 + onChange={(e) => { 104 + const val = e.currentTarget.value; 105 + setInputValue(val); 106 + if ( 107 + props.selectedUser && 108 + val !== `@${props.selectedUser.handle}` 109 + ) { 110 + props.onUserSelect(null); 111 + } 112 + combobox.openDropdown(); 113 + }} 114 + onFocus={() => combobox.openDropdown()} 115 + onBlur={() => combobox.closeDropdown()} 116 + leftSection={<MdOutlinePersonSearch size={16} />} 117 + rightSection={isFetching && <Loader size={16} />} 118 + w={200} 119 + /> 120 + </Combobox.Target> 121 + 122 + <Combobox.Dropdown 123 + hidden={debounced.trim().length === 0 && !props.selectedUser} 124 + > 125 + <Combobox.Options> 126 + <ScrollArea.Autosize type="scroll" mah={200}> 127 + {isFetching && <Combobox.Empty>Searching...</Combobox.Empty>} 128 + {error && ( 129 + <Combobox.Empty>Could not search for profiles</Combobox.Empty> 130 + )} 131 + {empty && <Combobox.Empty>No profiles found</Combobox.Empty>} 132 + {options.length > 0 && options} 133 + </ScrollArea.Autosize> 134 + </Combobox.Options> 135 + </Combobox.Dropdown> 136 + </Combobox> 137 + 138 + {props.selectedUser && ( 139 + <Button 140 + variant="subtle" 141 + size="xs" 142 + onClick={handleClear} 143 + leftSection={<MdClear size={14} />} 144 + > 145 + Clear 146 + </Button> 147 + )} 148 + </Group> 149 + ); 150 + }
+9
src/webapp/features/search/containers/searchContainer/Error.SearchContainer.tsx
··· 1 + import { Alert, Container } from '@mantine/core'; 2 + 3 + export default function SearchContainerError() { 4 + return ( 5 + <Container p="xs" size="xl"> 6 + <Alert color="red" title="Could not load search page" /> 7 + </Container> 8 + ); 9 + }
+60
src/webapp/features/search/containers/searchContainer/SearchContainer.tsx
··· 1 + 'use client'; 2 + 3 + import { useState } from 'react'; 4 + import { Container, Stack, TextInput, Button, Group } from '@mantine/core'; 5 + import { BiSearch } from 'react-icons/bi'; 6 + import SearchResultsContainer from '../searchResultsContainer/SearchResultsContainer'; 7 + import UserFilterCombobox from '../../components/userFilterCombobox/UserFilterCombobox'; 8 + import type { ProfileViewBasic } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; 9 + 10 + export default function SearchContainer() { 11 + const [query, setQuery] = useState(''); 12 + const [searchQuery, setSearchQuery] = useState(''); 13 + const [selectedUser, setSelectedUser] = useState<ProfileViewBasic | null>( 14 + null, 15 + ); 16 + const [searchUserId, setSearchUserId] = useState<string | undefined>( 17 + undefined, 18 + ); 19 + 20 + const handleSearch = () => { 21 + if (query.trim()) { 22 + setSearchQuery(query.trim()); 23 + setSearchUserId(selectedUser?.did); 24 + } 25 + }; 26 + 27 + const handleKeyPress = (event: React.KeyboardEvent) => { 28 + if (event.key === 'Enter') { 29 + handleSearch(); 30 + } 31 + }; 32 + 33 + return ( 34 + <Container p="xs" size="xl"> 35 + <Stack gap="lg"> 36 + <Group gap="xs"> 37 + <TextInput 38 + flex={1} 39 + placeholder="Search for cards..." 40 + value={query} 41 + onChange={(event) => setQuery(event.currentTarget.value)} 42 + onKeyPress={handleKeyPress} 43 + leftSection={<BiSearch size={16} />} 44 + /> 45 + <UserFilterCombobox 46 + selectedUser={selectedUser} 47 + onUserSelect={setSelectedUser} 48 + /> 49 + <Button onClick={handleSearch} disabled={!query.trim()}> 50 + Search 51 + </Button> 52 + </Group> 53 + 54 + {searchQuery && ( 55 + <SearchResultsContainer query={searchQuery} userId={searchUserId} /> 56 + )} 57 + </Stack> 58 + </Container> 59 + ); 60 + }
+14
src/webapp/features/search/containers/searchContainer/Skeleton.SearchContainer.tsx
··· 1 + import { Container, Stack, Skeleton, Group } from '@mantine/core'; 2 + 3 + export default function SearchContainerSkeleton() { 4 + return ( 5 + <Container p="xs" size="xl"> 6 + <Stack gap="lg"> 7 + <Group gap="xs"> 8 + <Skeleton flex={1} h={36} /> 9 + <Skeleton w={80} h={36} /> 10 + </Group> 11 + </Stack> 12 + </Container> 13 + ); 14 + }
+5
src/webapp/features/search/containers/searchResultsContainer/Error.SearchResultsContainer.tsx
··· 1 + import { Alert } from '@mantine/core'; 2 + 3 + export default function SearchResultsContainerError() { 4 + return <Alert color="red" title="Could not load search results" />; 5 + }
+52
src/webapp/features/search/containers/searchResultsContainer/SearchResultsContainer.tsx
··· 1 + 'use client'; 2 + 3 + import useSemanticSearch from '../../lib/queries/useSemanticSearch'; 4 + import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; 5 + import { Grid } from '@mantine/core'; 6 + import SearchResultsContainerError from './Error.SearchResultsContainer'; 7 + import SimilarUrlCard from '@/features/semble/components/similarUrlCard/SimilarUrlCard'; 8 + import SearchEmptyResults from '../../components/searchEmptyResults/SearchEmptyResults'; 9 + 10 + interface Props { 11 + query: string; 12 + userId?: string; 13 + } 14 + 15 + export default function SearchResultsContainer(props: Props) { 16 + const { 17 + data, 18 + error, 19 + fetchNextPage, 20 + hasNextPage, 21 + isFetchingNextPage, 22 + isPending, 23 + } = useSemanticSearch({ query: props.query, userId: props.userId }); 24 + 25 + const allUrls = data?.pages.flatMap((page) => page.urls ?? []) ?? []; 26 + 27 + if (error) { 28 + return <SearchResultsContainerError />; 29 + } 30 + 31 + if (!isPending && allUrls.length === 0) { 32 + return <SearchEmptyResults query={props.query} />; 33 + } 34 + 35 + return ( 36 + <InfiniteScroll 37 + dataLength={allUrls.length} 38 + hasMore={!!hasNextPage} 39 + isInitialLoading={isPending} 40 + isLoading={isFetchingNextPage} 41 + loadMore={fetchNextPage} 42 + > 43 + <Grid gutter="xs" mx={'auto'} maw={600}> 44 + {allUrls.map((urlView) => ( 45 + <Grid.Col key={urlView.url} span={12}> 46 + <SimilarUrlCard urlView={urlView} /> 47 + </Grid.Col> 48 + ))} 49 + </Grid> 50 + </InfiniteScroll> 51 + ); 52 + }
+29
src/webapp/features/search/lib/dal.ts
··· 1 + import { createSembleClient } from '@/services/client.apiClient'; 2 + import { cache } from 'react'; 3 + 4 + interface PageParams { 5 + page?: number; 6 + limit?: number; 7 + } 8 + 9 + interface SemanticSearchParams extends PageParams { 10 + threshold?: number; 11 + urlType?: string; 12 + userId?: string; 13 + } 14 + 15 + export const semanticSearchUrls = cache( 16 + async (query: string, params?: SemanticSearchParams) => { 17 + const client = createSembleClient(); 18 + const response = await client.semanticSearchUrls({ 19 + query, 20 + page: params?.page, 21 + limit: params?.limit, 22 + threshold: params?.threshold, 23 + urlType: params?.urlType, 24 + userId: params?.userId, 25 + }); 26 + 27 + return response; 28 + }, 29 + );
+43
src/webapp/features/search/lib/queries/useSemanticSearch.tsx
··· 1 + import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; 2 + import { semanticSearchUrls } from '../dal'; 3 + import { searchKeys } from '../searchKeys'; 4 + 5 + interface Props { 6 + query: string; 7 + limit?: number; 8 + threshold?: number; 9 + urlType?: string; 10 + userId?: string; 11 + } 12 + 13 + export default function useSemanticSearch(props: Props) { 14 + const limit = props?.limit ?? 16; 15 + 16 + const searchResults = useSuspenseInfiniteQuery({ 17 + queryKey: searchKeys.semanticSearchInfinite( 18 + props.query, 19 + props.limit, 20 + props.threshold, 21 + props.urlType, 22 + props.userId, 23 + ), 24 + initialPageParam: 1, 25 + queryFn: ({ pageParam = 1 }) => { 26 + return semanticSearchUrls(props.query, { 27 + page: pageParam, 28 + limit, 29 + ...(props.threshold && { threshold: props.threshold }), 30 + ...(props.urlType && { urlType: props.urlType }), 31 + ...(props.userId && { userId: props.userId }), 32 + }); 33 + }, 34 + getNextPageParam: (lastPage) => { 35 + if (lastPage.pagination.hasMore) { 36 + return lastPage.pagination.currentPage + 1; 37 + } 38 + return undefined; 39 + }, 40 + }); 41 + 42 + return searchResults; 43 + }
+20
src/webapp/features/search/lib/searchKeys.ts
··· 1 + export const searchKeys = { 2 + all: () => ['search'] as const, 3 + semanticSearch: (query: string) => 4 + [...searchKeys.all(), 'semantic', query] as const, 5 + semanticSearchInfinite: ( 6 + query: string, 7 + limit?: number, 8 + threshold?: number, 9 + urlType?: string, 10 + userId?: string, 11 + ) => 12 + [ 13 + ...searchKeys.semanticSearch(query), 14 + 'infinite', 15 + limit, 16 + threshold, 17 + urlType, 18 + userId, 19 + ] as const, 20 + };
+24
src/webapp/lib/clientFeatureFlags.ts
··· 1 + 'use client'; 2 + 3 + import { useQuery } from '@tanstack/react-query'; 4 + 5 + interface FeatureFlags { 6 + cardSearch: boolean; 7 + } 8 + 9 + async function fetchFeatureFlags(): Promise<FeatureFlags> { 10 + const response = await fetch('/api/feature-flags'); 11 + if (!response.ok) { 12 + throw new Error('Failed to fetch feature flags'); 13 + } 14 + return response.json(); 15 + } 16 + 17 + export function useFeatureFlags() { 18 + return useQuery({ 19 + queryKey: ['feature-flags'], 20 + queryFn: fetchFeatureFlags, 21 + staleTime: 5 * 60 * 1000, // 5 minutes 22 + gcTime: 10 * 60 * 1000, // 10 minutes 23 + }); 24 + }
+13
src/webapp/lib/serverFeatureFlags.ts
··· 1 + import { getServerAuthStatus } from './serverAuth'; 2 + 3 + const APPROVED_HANDLES = ['wesleyfinck.org', 'ronentk.me', 'pouriade.com']; 4 + 5 + export async function getServerFeatureFlags() { 6 + const { user } = await getServerAuthStatus(); 7 + 8 + const isApprovedUser = user?.handle && APPROVED_HANDLES.includes(user.handle); 9 + 10 + return { 11 + cardSearch: isApprovedUser || process.env.VERCEL_ENV !== 'production', 12 + }; 13 + }