alpha
Login
or
Join now
nandi.uk
/
semble
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
This repository has no description
Star
0
Fork
0
Atom
Configure Feed
Issues
Pull Requests
Commits
Tags
Feed URL
Select the types of activity you want to include in your feed.
Overview
Issues
Pulls
Pipelines
feat: url types on explore
author
Pouria Delfanazari
date
6 months ago
(Jan 13, 2026, 1:53 PM -0800)
commit
546d61dc
546d61dc4ff43f70638e6b8012fae92877e587ae
parent
8d210e7e
8d210e7e53cde39cf341ba607986be63eabbc6a4
+181
-52
10 changed files
Expand all
Collapse all
Unified
Split
src
webapp
app
api
feature-flags
route.ts
features
feeds
components
feedControls
FeedControls.tsx
feedFilters
FeedFilters.tsx
containers
gemsFeedContainer
GemsFeedContainer.tsx
myFeedContainer
MyFeedContainer.tsx
lib
feedKeys.ts
queries
useGemsFeed.tsx
useGlobalFeed.tsx
lib
clientFeatureFlags.ts
serverFeatureFlags.ts
+1
src/webapp/app/api/feature-flags/route.ts
View file
Reviewed
···
11
11
return NextResponse.json({
12
12
similarCards: false,
13
13
cardSearch: false,
14
14
+
urlTypeFilter: false,
14
15
});
15
16
}
16
17
}
+50
-44
src/webapp/features/feeds/components/feedControls/FeedControls.tsx
View file
Reviewed
···
10
10
import Link from 'next/link';
11
11
import { usePathname, useRouter } from 'next/navigation';
12
12
import { BiCollection } from 'react-icons/bi';
13
13
+
import FeedFilters from '../feedFilters/FeedFilters';
14
14
+
import { useFeatureFlags } from '@/lib/clientFeatureFlags';
13
15
14
16
const options = [
15
17
{ value: 'explore', label: 'Latest', href: '/explore' },
···
23
25
export default function FeedControls() {
24
26
const pathname = usePathname();
25
27
const router = useRouter();
28
28
+
const { data: featureFlags } = useFeatureFlags();
26
29
27
30
const segment = pathname.split('/')[2];
28
31
const currentValue = segment || 'explore';
···
36
39
37
40
return (
38
41
<ScrollAreaAutosize type="scroll">
39
39
-
<Group gap={'xs'} wrap="nowrap">
40
40
-
<Combobox
41
41
-
store={combobox}
42
42
-
onOptionSubmit={(value) => {
43
43
-
const option = options.find((o) => o.value === value);
44
44
-
if (option) {
45
45
-
router.push(option.href);
46
46
-
}
47
47
-
combobox.closeDropdown();
48
48
-
}}
49
49
-
width={200}
50
50
-
>
51
51
-
<Combobox.Target>
42
42
+
<Group gap={'xs'} justify="space-between" wrap="nowrap">
43
43
+
<Group gap={'xs'}>
44
44
+
<Combobox
45
45
+
store={combobox}
46
46
+
onOptionSubmit={(value) => {
47
47
+
const option = options.find((o) => o.value === value);
48
48
+
if (option) {
49
49
+
router.push(option.href);
50
50
+
}
51
51
+
combobox.closeDropdown();
52
52
+
}}
53
53
+
width={200}
54
54
+
>
55
55
+
<Combobox.Target>
56
56
+
<Button
57
57
+
variant="light"
58
58
+
color="gray"
59
59
+
leftSection={<Combobox.Chevron />}
60
60
+
onClick={() => combobox.toggleDropdown()}
61
61
+
>
62
62
+
{selected?.label || 'Select feed'}
63
63
+
</Button>
64
64
+
</Combobox.Target>
65
65
+
66
66
+
<Combobox.Dropdown>
67
67
+
<Combobox.Options>
68
68
+
{options.map((option) => (
69
69
+
<Combobox.Option
70
70
+
key={option.value}
71
71
+
value={option.value}
72
72
+
active={option.value === currentValue}
73
73
+
>
74
74
+
{option.label}
75
75
+
</Combobox.Option>
76
76
+
))}
77
77
+
</Combobox.Options>
78
78
+
</Combobox.Dropdown>
79
79
+
</Combobox>
80
80
+
{isGemsFeed && (
52
81
<Button
53
82
variant="light"
54
54
-
color="gray"
55
55
-
leftSection={<Combobox.Chevron />}
56
56
-
onClick={() => combobox.toggleDropdown()}
83
83
+
color="grape"
84
84
+
component={Link}
85
85
+
href={'/explore/gems-of-2025/collections'}
86
86
+
leftSection={<BiCollection size={18} />}
57
87
>
58
58
-
{selected?.label || 'Select feed'}
88
88
+
Gem Collections
59
89
</Button>
60
60
-
</Combobox.Target>
61
61
-
62
62
-
<Combobox.Dropdown>
63
63
-
<Combobox.Options>
64
64
-
{options.map((option) => (
65
65
-
<Combobox.Option
66
66
-
key={option.value}
67
67
-
value={option.value}
68
68
-
active={option.value === currentValue}
69
69
-
>
70
70
-
{option.label}
71
71
-
</Combobox.Option>
72
72
-
))}
73
73
-
</Combobox.Options>
74
74
-
</Combobox.Dropdown>
75
75
-
</Combobox>
76
76
-
{isGemsFeed && (
77
77
-
<Button
78
78
-
variant="light"
79
79
-
color="grape"
80
80
-
component={Link}
81
81
-
href={'/explore/gems-of-2025/collections'}
82
82
-
leftSection={<BiCollection size={18} />}
83
83
-
>
84
84
-
Gem Collections
85
85
-
</Button>
86
86
-
)}
90
90
+
)}
91
91
+
</Group>
92
92
+
{featureFlags?.urlTypeFilter && <FeedFilters />}
87
93
</Group>
88
94
</ScrollAreaAutosize>
89
95
);
+90
src/webapp/features/feeds/components/feedFilters/FeedFilters.tsx
View file
Reviewed
···
1
1
+
'use client';
2
2
+
3
3
+
import { getUrlTypeIcon } from '@/lib/utils/icon';
4
4
+
import { Button, Group, Popover } from '@mantine/core';
5
5
+
import { upperFirst } from '@mantine/hooks';
6
6
+
import { UrlType } from '@semble/types';
7
7
+
import { useRouter, useSearchParams } from 'next/navigation';
8
8
+
import { useOptimistic, useState, useTransition } from 'react';
9
9
+
import { MdFilterList } from 'react-icons/md';
10
10
+
11
11
+
export default function FeedFilters() {
12
12
+
const [opened, setOpened] = useState(false);
13
13
+
const router = useRouter();
14
14
+
const searchParams = useSearchParams();
15
15
+
16
16
+
const typeFromUrl = searchParams.get('type') as UrlType | null;
17
17
+
18
18
+
const [optimisticType, setOptimisticType] = useOptimistic<UrlType | null>(
19
19
+
typeFromUrl,
20
20
+
);
21
21
+
22
22
+
const [, startTransition] = useTransition();
23
23
+
24
24
+
const SelectedIcon =
25
25
+
optimisticType === null ? MdFilterList : getUrlTypeIcon(optimisticType);
26
26
+
27
27
+
const handleFilterClick = (type?: UrlType) => {
28
28
+
const nextType = type ?? null;
29
29
+
30
30
+
startTransition(() => {
31
31
+
setOptimisticType(nextType);
32
32
+
33
33
+
const params = new URLSearchParams(searchParams.toString());
34
34
+
if (nextType) {
35
35
+
params.set('type', nextType);
36
36
+
} else {
37
37
+
params.delete('type');
38
38
+
}
39
39
+
40
40
+
router.push(`?${params.toString()}`, { scroll: false });
41
41
+
});
42
42
+
43
43
+
setOpened(false);
44
44
+
};
45
45
+
46
46
+
return (
47
47
+
<Popover opened={opened} onChange={setOpened} shadow="sm">
48
48
+
<Popover.Target>
49
49
+
<Button
50
50
+
variant="light"
51
51
+
color="lime"
52
52
+
leftSection={<SelectedIcon />}
53
53
+
onClick={() => setOpened((o) => !o)}
54
54
+
>
55
55
+
{optimisticType ? upperFirst(optimisticType) : 'All Cards'}
56
56
+
</Button>
57
57
+
</Popover.Target>
58
58
+
59
59
+
<Popover.Dropdown maw={300}>
60
60
+
<Group gap={6}>
61
61
+
<Button
62
62
+
size="xs"
63
63
+
color="lime"
64
64
+
variant={optimisticType === null ? 'filled' : 'light'}
65
65
+
onClick={() => handleFilterClick()}
66
66
+
>
67
67
+
All Cards
68
68
+
</Button>
69
69
+
70
70
+
{Object.values(UrlType).map((type) => {
71
71
+
const Icon = getUrlTypeIcon(type);
72
72
+
73
73
+
return (
74
74
+
<Button
75
75
+
key={type}
76
76
+
size="xs"
77
77
+
color="lime"
78
78
+
variant={optimisticType === type ? 'filled' : 'light'}
79
79
+
leftSection={<Icon />}
80
80
+
onClick={() => handleFilterClick(type)}
81
81
+
>
82
82
+
{upperFirst(type)}
83
83
+
</Button>
84
84
+
);
85
85
+
})}
86
86
+
</Group>
87
87
+
</Popover.Dropdown>
88
88
+
</Popover>
89
89
+
);
90
90
+
}
+6
-1
src/webapp/features/feeds/containers/gemsFeedContainer/GemsFeedContainer.tsx
View file
Reviewed
···
7
7
import GemsFeedContainerError from './Error.GemsFeedContainer';
8
8
import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll';
9
9
import RefetchButton from '@/components/navigation/refetchButton/RefetchButton';
10
10
+
import { useSearchParams } from 'next/navigation';
11
11
+
import { UrlType } from '@semble/types';
10
12
11
13
export default function GemsFeedContainer() {
14
14
+
const searchParams = useSearchParams();
15
15
+
const selectedUrlType = searchParams.get('type') as UrlType;
16
16
+
12
17
const {
13
18
data,
14
19
error,
···
18
23
isFetchingNextPage,
19
24
isRefetching,
20
25
refetch,
21
21
-
} = useGemsFeed();
26
26
+
} = useGemsFeed({ urlType: selectedUrlType });
22
27
23
28
const allActivities =
24
29
data?.pages.flatMap((page) => page.activities ?? []) ?? [];
+6
-1
src/webapp/features/feeds/containers/myFeedContainer/MyFeedContainer.tsx
View file
Reviewed
···
7
7
import MyFeedContainerError from './Error.MyFeedContainer';
8
8
import InfiniteScroll from '@/components/contentDisplay/infiniteScroll/InfiniteScroll';
9
9
import RefetchButton from '@/components/navigation/refetchButton/RefetchButton';
10
10
+
import { UrlType } from '@semble/types';
11
11
+
import { useSearchParams } from 'next/navigation';
10
12
11
13
export default function MyFeedContainer() {
14
14
+
const searchParams = useSearchParams();
15
15
+
const selectedUrlType = searchParams.get('type') as UrlType;
16
16
+
12
17
const {
13
18
data,
14
19
error,
···
18
23
isFetchingNextPage,
19
24
isRefetching,
20
25
refetch,
21
21
-
} = useGlobalFeed();
26
26
+
} = useGlobalFeed({ urlType: selectedUrlType });
22
27
23
28
const allActivities =
24
29
data?.pages.flatMap((page) => page.activities ?? []) ?? [];
+14
-2
src/webapp/features/feeds/lib/feedKeys.ts
View file
Reviewed
···
1
1
+
import { UrlType } from '@semble/types';
2
2
+
1
3
export const feedKeys = {
2
4
all: () => ['feeds'] as const,
3
3
-
infinite: (limit?: number) => [...feedKeys.all(), 'infinite', limit],
5
5
+
infinite: (limit?: number, urlType?: UrlType) => [
6
6
+
...feedKeys.all(),
7
7
+
'infinite',
8
8
+
limit,
9
9
+
urlType,
10
10
+
],
4
11
gems: () => [...feedKeys.all(), 'gems'] as const,
5
5
-
gemsInfinite: (limit?: number) => [...feedKeys.gems(), 'infinite', limit],
12
12
+
gemsInfinite: (limit?: number, urlType?: UrlType) => [
13
13
+
...feedKeys.gems(),
14
14
+
[...feedKeys.infinite()],
15
15
+
urlType,
16
16
+
limit,
17
17
+
],
6
18
};
+8
-2
src/webapp/features/feeds/lib/queries/useGemsFeed.tsx
View file
Reviewed
···
1
1
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
2
2
import { getGemsActivityFeed } from '../dal';
3
3
import { feedKeys } from '../feedKeys';
4
4
+
import { UrlType } from '@semble/types';
4
5
5
6
interface Props {
6
7
limit?: number;
8
8
+
urlType?: UrlType;
7
9
}
8
10
9
11
export default function useGemsFeed(props?: Props) {
10
12
const limit = props?.limit ?? 15;
11
13
12
14
const query = useSuspenseInfiniteQuery({
13
13
-
queryKey: feedKeys.gemsInfinite(limit),
15
15
+
queryKey: feedKeys.gemsInfinite(limit, props?.urlType),
14
16
staleTime: 10000,
15
17
initialPageParam: 1,
16
18
refetchOnWindowFocus: false,
17
19
queryFn: ({ pageParam = 1 }) => {
18
18
-
return getGemsActivityFeed({ limit, page: pageParam });
20
20
+
return getGemsActivityFeed({
21
21
+
limit,
22
22
+
page: pageParam,
23
23
+
urlType: props?.urlType,
24
24
+
});
19
25
},
20
26
getNextPageParam: (lastPage) => {
21
27
if (lastPage.pagination.hasMore) {
+4
-2
src/webapp/features/feeds/lib/queries/useGlobalFeed.tsx
View file
Reviewed
···
1
1
import { useSuspenseInfiniteQuery } from '@tanstack/react-query';
2
2
import { getGlobalFeed } from '../dal';
3
3
import { feedKeys } from '../feedKeys';
4
4
+
import { UrlType } from '@semble/types';
4
5
5
6
interface Props {
6
7
limit?: number;
8
8
+
urlType?: UrlType;
7
9
}
8
10
9
11
export default function useGlobalFeed(props?: Props) {
10
12
const limit = props?.limit ?? 15;
11
13
12
14
const query = useSuspenseInfiniteQuery({
13
13
-
queryKey: feedKeys.infinite(limit),
15
15
+
queryKey: feedKeys.infinite(limit, props?.urlType),
14
16
staleTime: 10000,
15
17
initialPageParam: 1,
16
18
refetchOnWindowFocus: false,
17
19
queryFn: ({ pageParam = 1 }) => {
18
18
-
return getGlobalFeed({ limit, page: pageParam });
20
20
+
return getGlobalFeed({ limit, page: pageParam, urlType: props?.urlType });
19
21
},
20
22
getNextPageParam: (lastPage) => {
21
23
if (lastPage.pagination.hasMore) {
+1
src/webapp/lib/clientFeatureFlags.ts
View file
Reviewed
···
4
4
5
5
interface FeatureFlags {
6
6
cardSearch: boolean;
7
7
+
urlTypeFilter: boolean;
7
8
}
8
9
9
10
async function fetchFeatureFlags(): Promise<FeatureFlags> {
+1
src/webapp/lib/serverFeatureFlags.ts
View file
Reviewed
···
9
9
10
10
return {
11
11
cardSearch: isApprovedUser || process.env.VERCEL_ENV !== 'production',
12
12
+
urlTypeFilter: isApprovedUser || process.env.VERCEL_ENV !== 'production',
12
13
};
13
14
}