diff --git a/src/webapp/app/api/feature-flags/route.ts b/src/webapp/app/api/feature-flags/route.ts index 62f566fd..c3188adf 100644 --- a/src/webapp/app/api/feature-flags/route.ts +++ b/src/webapp/app/api/feature-flags/route.ts @@ -11,6 +11,7 @@ export async function GET() { return NextResponse.json({ similarCards: false, cardSearch: false, + urlTypeFilter: false, }); } } diff --git a/src/webapp/features/feeds/components/feedControls/FeedControls.tsx b/src/webapp/features/feeds/components/feedControls/FeedControls.tsx index 92f3a540..0dcc91dd 100644 --- a/src/webapp/features/feeds/components/feedControls/FeedControls.tsx +++ b/src/webapp/features/feeds/components/feedControls/FeedControls.tsx @@ -10,6 +10,8 @@ import { import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { BiCollection } from 'react-icons/bi'; +import FeedFilters from '../feedFilters/FeedFilters'; +import { useFeatureFlags } from '@/lib/clientFeatureFlags'; const options = [ { value: 'explore', label: 'Latest', href: '/explore' }, @@ -23,6 +25,7 @@ const options = [ export default function FeedControls() { const pathname = usePathname(); const router = useRouter(); + const { data: featureFlags } = useFeatureFlags(); const segment = pathname.split('/')[2]; const currentValue = segment || 'explore'; @@ -36,54 +39,57 @@ export default function FeedControls() { return ( - - { - const option = options.find((o) => o.value === value); - if (option) { - router.push(option.href); - } - combobox.closeDropdown(); - }} - width={200} - > - + + + { + const option = options.find((o) => o.value === value); + if (option) { + router.push(option.href); + } + combobox.closeDropdown(); + }} + width={200} + > + + + + + + + {options.map((option) => ( + + {option.label} + + ))} + + + + {isGemsFeed && ( - - - - - {options.map((option) => ( - - {option.label} - - ))} - - - - {isGemsFeed && ( - - )} + )} + + {featureFlags?.urlTypeFilter && } ); diff --git a/src/webapp/features/feeds/components/feedFilters/FeedFilters.tsx b/src/webapp/features/feeds/components/feedFilters/FeedFilters.tsx new file mode 100644 index 00000000..3de754fb --- /dev/null +++ b/src/webapp/features/feeds/components/feedFilters/FeedFilters.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { getUrlTypeIcon } from '@/lib/utils/icon'; +import { Button, Group, Popover } from '@mantine/core'; +import { upperFirst } from '@mantine/hooks'; +import { UrlType } from '@semble/types'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useOptimistic, useState, useTransition } from 'react'; +import { MdFilterList } from 'react-icons/md'; + +export default function FeedFilters() { + const [opened, setOpened] = useState(false); + const router = useRouter(); + const searchParams = useSearchParams(); + + const typeFromUrl = searchParams.get('type') as UrlType | null; + + const [optimisticType, setOptimisticType] = useOptimistic( + typeFromUrl, + ); + + const [, startTransition] = useTransition(); + + const SelectedIcon = + optimisticType === null ? MdFilterList : getUrlTypeIcon(optimisticType); + + const handleFilterClick = (type?: UrlType) => { + const nextType = type ?? null; + + startTransition(() => { + setOptimisticType(nextType); + + const params = new URLSearchParams(searchParams.toString()); + if (nextType) { + params.set('type', nextType); + } else { + params.delete('type'); + } + + router.push(`?${params.toString()}`, { scroll: false }); + }); + + setOpened(false); + }; + + return ( + + + + + + + + + + {Object.values(UrlType).map((type) => { + const Icon = getUrlTypeIcon(type); + + return ( + + ); + })} + + + + ); +} diff --git a/src/webapp/features/feeds/containers/gemsFeedContainer/GemsFeedContainer.tsx b/src/webapp/features/feeds/containers/gemsFeedContainer/GemsFeedContainer.tsx index 71fa9cf7..1996403b 100644 --- a/src/webapp/features/feeds/containers/gemsFeedContainer/GemsFeedContainer.tsx +++ b/src/webapp/features/feeds/containers/gemsFeedContainer/GemsFeedContainer.tsx @@ -7,8 +7,13 @@ import GemsFeedContainerSkeleton from './Skeleton.GemsFeedContainer'; import GemsFeedContainerError from './Error.GemsFeedContainer'; import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; import RefetchButton from '@/components/navigation/refetchButton/RefetchButton'; +import { useSearchParams } from 'next/navigation'; +import { UrlType } from '@semble/types'; export default function GemsFeedContainer() { + const searchParams = useSearchParams(); + const selectedUrlType = searchParams.get('type') as UrlType; + const { data, error, @@ -18,7 +23,7 @@ export default function GemsFeedContainer() { isFetchingNextPage, isRefetching, refetch, - } = useGemsFeed(); + } = useGemsFeed({ urlType: selectedUrlType }); const allActivities = data?.pages.flatMap((page) => page.activities ?? []) ?? []; diff --git a/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx b/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx index 95fa9e91..6e5bbf95 100644 --- a/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx +++ b/src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx @@ -7,8 +7,13 @@ import MyFeedContainerSkeleton from './Skeleton.MyFeedContainer'; import MyFeedContainerError from './Error.MyFeedContainer'; import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll'; import RefetchButton from '@/components/navigation/refetchButton/RefetchButton'; +import { UrlType } from '@semble/types'; +import { useSearchParams } from 'next/navigation'; export default function MyFeedContainer() { + const searchParams = useSearchParams(); + const selectedUrlType = searchParams.get('type') as UrlType; + const { data, error, @@ -18,7 +23,7 @@ export default function MyFeedContainer() { isFetchingNextPage, isRefetching, refetch, - } = useGlobalFeed(); + } = useGlobalFeed({ urlType: selectedUrlType }); const allActivities = data?.pages.flatMap((page) => page.activities ?? []) ?? []; diff --git a/src/webapp/features/feeds/lib/feedKeys.ts b/src/webapp/features/feeds/lib/feedKeys.ts index 7f84f901..ca8cfbf8 100644 --- a/src/webapp/features/feeds/lib/feedKeys.ts +++ b/src/webapp/features/feeds/lib/feedKeys.ts @@ -1,6 +1,18 @@ +import { UrlType } from '@semble/types'; + export const feedKeys = { all: () => ['feeds'] as const, - infinite: (limit?: number) => [...feedKeys.all(), 'infinite', limit], + infinite: (limit?: number, urlType?: UrlType) => [ + ...feedKeys.all(), + 'infinite', + limit, + urlType, + ], gems: () => [...feedKeys.all(), 'gems'] as const, - gemsInfinite: (limit?: number) => [...feedKeys.gems(), 'infinite', limit], + gemsInfinite: (limit?: number, urlType?: UrlType) => [ + ...feedKeys.gems(), + [...feedKeys.infinite()], + urlType, + limit, + ], }; diff --git a/src/webapp/features/feeds/lib/queries/useGemsFeed.tsx b/src/webapp/features/feeds/lib/queries/useGemsFeed.tsx index 37aa8489..4de7bffc 100644 --- a/src/webapp/features/feeds/lib/queries/useGemsFeed.tsx +++ b/src/webapp/features/feeds/lib/queries/useGemsFeed.tsx @@ -1,21 +1,27 @@ import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; import { getGemsActivityFeed } from '../dal'; import { feedKeys } from '../feedKeys'; +import { UrlType } from '@semble/types'; interface Props { limit?: number; + urlType?: UrlType; } export default function useGemsFeed(props?: Props) { const limit = props?.limit ?? 15; const query = useSuspenseInfiniteQuery({ - queryKey: feedKeys.gemsInfinite(limit), + queryKey: feedKeys.gemsInfinite(limit, props?.urlType), staleTime: 10000, initialPageParam: 1, refetchOnWindowFocus: false, queryFn: ({ pageParam = 1 }) => { - return getGemsActivityFeed({ limit, page: pageParam }); + return getGemsActivityFeed({ + limit, + page: pageParam, + urlType: props?.urlType, + }); }, getNextPageParam: (lastPage) => { if (lastPage.pagination.hasMore) { diff --git a/src/webapp/features/feeds/lib/queries/useGlobalFeed.tsx b/src/webapp/features/feeds/lib/queries/useGlobalFeed.tsx index ec2aedc9..0cae2cec 100644 --- a/src/webapp/features/feeds/lib/queries/useGlobalFeed.tsx +++ b/src/webapp/features/feeds/lib/queries/useGlobalFeed.tsx @@ -1,21 +1,23 @@ import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; import { getGlobalFeed } from '../dal'; import { feedKeys } from '../feedKeys'; +import { UrlType } from '@semble/types'; interface Props { limit?: number; + urlType?: UrlType; } export default function useGlobalFeed(props?: Props) { const limit = props?.limit ?? 15; const query = useSuspenseInfiniteQuery({ - queryKey: feedKeys.infinite(limit), + queryKey: feedKeys.infinite(limit, props?.urlType), staleTime: 10000, initialPageParam: 1, refetchOnWindowFocus: false, queryFn: ({ pageParam = 1 }) => { - return getGlobalFeed({ limit, page: pageParam }); + return getGlobalFeed({ limit, page: pageParam, urlType: props?.urlType }); }, getNextPageParam: (lastPage) => { if (lastPage.pagination.hasMore) { diff --git a/src/webapp/lib/clientFeatureFlags.ts b/src/webapp/lib/clientFeatureFlags.ts index 8777c6f6..cdb678a6 100644 --- a/src/webapp/lib/clientFeatureFlags.ts +++ b/src/webapp/lib/clientFeatureFlags.ts @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'; interface FeatureFlags { cardSearch: boolean; + urlTypeFilter: boolean; } async function fetchFeatureFlags(): Promise { diff --git a/src/webapp/lib/serverFeatureFlags.ts b/src/webapp/lib/serverFeatureFlags.ts index 1c5e0de0..1c1cca85 100644 --- a/src/webapp/lib/serverFeatureFlags.ts +++ b/src/webapp/lib/serverFeatureFlags.ts @@ -9,5 +9,6 @@ export async function getServerFeatureFlags() { return { cardSearch: isApprovedUser || process.env.VERCEL_ENV !== 'production', + urlTypeFilter: isApprovedUser || process.env.VERCEL_ENV !== 'production', }; }