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
UI for the semble page counts
author
Wesley Finck
date
4 months ago
(Mar 11, 2026, 4:35 PM -0700)
commit
b83ac32a
b83ac32a32ba9de1c5f0d79f05fe36ac2efae28c
parent
3155b6b9
3155b6b9b55c26b6707addf35cb881f45bdee4b3
+173
-21
18 changed files
Expand all
Collapse all
Unified
Split
src
webapp
app
(dashboard)
url
page.tsx
api
opengraph
semble
route.tsx
features
cards
lib
cardKeys.ts
dal.ts
mutations
useAddCard.tsx
useRemoveCardFromCollections.tsx
useRemoveCardFromLibrary.tsx
useUpdateCardAssociations.tsx
queries
useUrlMetadata.tsx
connections
lib
mutations
useCreateConnection.tsx
useDeleteConnection.tsx
useUpdateConnection.tsx
graph
lib
queries
useGraphNodeUrl.tsx
platforms
bluesky
components
blueskySemblePost
BlueskySemblePost.tsx
semble
components
sembleTabs
SembleTabs.tsx
TabItem.tsx
urlMetadataHeader
UrlMetadataHeader.tsx
containers
sembleContainer
SembleContainer.tsx
+5
-2
src/webapp/app/(dashboard)/url/page.tsx
View file
Reviewed
···
22
22
redirect('/');
23
23
}
24
24
25
25
-
const { metadata } = await getUrlMetadata(url);
25
25
+
const { metadata } = await getUrlMetadata({ url, includeStats: true });
26
26
const domain = getDomain(url);
27
27
const title = metadata.title ? `${metadata.title} (${domain})` : url;
28
28
···
51
51
redirect('/');
52
52
}
53
53
54
54
+
// Fetch metadata with stats for the tabs
55
55
+
const { stats } = await getUrlMetadata({ url, includeStats: true });
56
56
+
54
57
return (
55
58
<SemblePageClient viaCardId={viaCardId}>
56
59
<Suspense fallback={<SembleContainerSkeleton />} key={url + 'container'}>
57
57
-
<SembleContainer url={url} viaCardId={viaCardId} />
60
60
+
<SembleContainer url={url} viaCardId={viaCardId} stats={stats} />
58
61
</Suspense>
59
62
</SemblePageClient>
60
63
);
+1
-1
src/webapp/app/api/opengraph/semble/route.tsx
View file
Reviewed
···
22
22
let metadata: Metadata = {};
23
23
if (url) {
24
24
try {
25
25
-
const result = await getUrlMetadata(getUrlFromSlug([url]));
25
25
+
const result = await getUrlMetadata({ url: getUrlFromSlug([url]) });
26
26
const libraries = await getLibrariesForUrl(getUrlFromSlug([url]));
27
27
28
28
metadata = {
+2
src/webapp/features/cards/lib/cardKeys.ts
View file
Reviewed
···
23
23
sortOrder,
24
24
urlType,
25
25
],
26
26
+
urlMetadata: (url: string, options?: { includeStats?: boolean }) =>
27
27
+
[...cardKeys.all(), 'metadata', url, options] as const,
26
28
};
+8
-3
src/webapp/features/cards/lib/dal.ts
View file
Reviewed
···
1
1
import { verifySessionOnClient, logoutUser } from '@/lib/auth/dal';
2
2
import { createSembleClient } from '@/services/client.apiClient';
3
3
-
import { CardSortField, SortOrder, UrlType } from '@semble/types';
3
3
+
import {
4
4
+
CardSortField,
5
5
+
GetUrlMetadataParams,
6
6
+
SortOrder,
7
7
+
UrlType,
8
8
+
} from '@semble/types';
4
9
import { cache } from 'react';
5
10
6
11
interface PageParams {
···
11
16
urlType?: UrlType;
12
17
}
13
18
14
14
-
export const getUrlMetadata = cache(async (url: string) => {
19
19
+
export const getUrlMetadata = cache(async (params: GetUrlMetadataParams) => {
15
20
const client = createSembleClient();
16
16
-
const response = await client.getUrlMetadata(url);
21
21
+
const response = await client.getUrlMetadata(params);
17
22
18
23
return response;
19
24
});
+4
src/webapp/features/cards/lib/mutations/useAddCard.tsx
View file
Reviewed
···
45
45
queryClient.invalidateQueries({
46
46
queryKey: collectionKeys.bySembleUrl(variables.url),
47
47
});
48
48
+
// Invalidate URL metadata with stats to update tab counts
49
49
+
queryClient.invalidateQueries({
50
50
+
queryKey: cardKeys.urlMetadata(variables.url, { includeStats: true }),
51
51
+
});
48
52
49
53
// invalidate each collection query individually
50
54
variables.collectionIds?.forEach((id) => {
+16
src/webapp/features/cards/lib/mutations/useRemoveCardFromCollections.tsx
View file
Reviewed
···
1
1
import { useMutation, useQueryClient } from '@tanstack/react-query';
2
2
import { removeCardFromCollection } from '../dal';
3
3
import { collectionKeys } from '@/features/collections/lib/collectionKeys';
4
4
+
import { sembleKeys } from '@/features/semble/lib/sembleKeys';
4
5
5
6
export default function useRemoveCardFromCollections() {
6
7
const queryClient = useQueryClient();
···
20
21
queryClient.invalidateQueries({ queryKey: collectionKeys.infinite() });
21
22
queryClient.invalidateQueries({ queryKey: collectionKeys.mine() });
22
23
queryClient.invalidateQueries({ queryKey: collectionKeys.all() });
24
24
+
queryClient.invalidateQueries({ queryKey: sembleKeys.all() });
25
25
+
// Invalidate all URL metadata queries with stats to update tab counts
26
26
+
queryClient.invalidateQueries({
27
27
+
predicate: (query): boolean => {
28
28
+
const key = query.queryKey as unknown[];
29
29
+
return !!(
30
30
+
key[0] === 'cards' &&
31
31
+
key[1] === 'metadata' &&
32
32
+
key[3] &&
33
33
+
typeof key[3] === 'object' &&
34
34
+
'includeStats' in key[3] &&
35
35
+
key[3].includeStats === true
36
36
+
);
37
37
+
},
38
38
+
});
23
39
24
40
variables.collectionIds.forEach((id) => {
25
41
queryClient.invalidateQueries({
+14
src/webapp/features/cards/lib/mutations/useRemoveCardFromLibrary.tsx
View file
Reviewed
···
20
20
queryClient.invalidateQueries({ queryKey: feedKeys.all() });
21
21
queryClient.invalidateQueries({ queryKey: collectionKeys.all() });
22
22
queryClient.invalidateQueries({ queryKey: sembleKeys.all() });
23
23
+
// Invalidate all URL metadata queries with stats to update tab counts
24
24
+
queryClient.invalidateQueries({
25
25
+
predicate: (query): boolean => {
26
26
+
const key = query.queryKey as unknown[];
27
27
+
return !!(
28
28
+
key[0] === 'cards' &&
29
29
+
key[1] === 'metadata' &&
30
30
+
key[3] &&
31
31
+
typeof key[3] === 'object' &&
32
32
+
'includeStats' in key[3] &&
33
33
+
key[3].includeStats === true
34
34
+
);
35
35
+
},
36
36
+
});
23
37
},
24
38
});
25
39
+14
src/webapp/features/cards/lib/mutations/useUpdateCardAssociations.tsx
View file
Reviewed
···
43
43
queryClient.invalidateQueries({ queryKey: feedKeys.all() });
44
44
queryClient.invalidateQueries({ queryKey: sembleKeys.all() });
45
45
queryClient.invalidateQueries({ queryKey: collectionKeys.all() });
46
46
+
// Invalidate all URL metadata queries with stats to update tab counts
47
47
+
queryClient.invalidateQueries({
48
48
+
predicate: (query): boolean => {
49
49
+
const key = query.queryKey as unknown[];
50
50
+
return !!(
51
51
+
key[0] === 'cards' &&
52
52
+
key[1] === 'metadata' &&
53
53
+
key[3] &&
54
54
+
typeof key[3] === 'object' &&
55
55
+
'includeStats' in key[3] &&
56
56
+
key[3].includeStats === true
57
57
+
);
58
58
+
},
59
59
+
});
46
60
47
61
// invalidate each collection query individually
48
62
variables.addToCollectionIds?.forEach((id) => {
+29
-6
src/webapp/features/cards/lib/queries/useUrlMetadata.tsx
View file
Reviewed
···
1
1
-
import { useSuspenseQuery } from '@tanstack/react-query';
1
1
+
import { useSuspenseQuery, useQuery } from '@tanstack/react-query';
2
2
import { getUrlMetadata } from '../dal';
3
3
+
import type { UrlAggregateStats } from '@semble/types';
4
4
+
import { cardKeys } from '../cardKeys';
3
5
4
4
-
interface Props {
6
6
+
interface PropsWithStats {
5
7
url: string;
8
8
+
includeStats: true;
9
9
+
initialData?: { stats?: UrlAggregateStats };
6
10
}
7
11
12
12
+
interface PropsWithoutStats {
13
13
+
url: string;
14
14
+
includeStats?: false;
15
15
+
}
16
16
+
17
17
+
type Props = PropsWithStats | PropsWithoutStats;
18
18
+
8
19
export default function useUrlMetadata(props: Props) {
9
9
-
const metadata = useSuspenseQuery({
10
10
-
queryKey: [props.url],
11
11
-
queryFn: () => getUrlMetadata(props.url),
20
20
+
// Use regular useQuery when we have initialData (with stats)
21
21
+
// Use useSuspenseQuery for basic metadata queries without stats
22
22
+
if (props.includeStats && 'initialData' in props) {
23
23
+
return useQuery({
24
24
+
queryKey: cardKeys.urlMetadata(props.url, { includeStats: true }),
25
25
+
queryFn: () => getUrlMetadata({ url: props.url, includeStats: true }),
26
26
+
initialData: props.initialData,
27
27
+
});
28
28
+
}
29
29
+
30
30
+
return useSuspenseQuery({
31
31
+
queryKey: cardKeys.urlMetadata(props.url, {
32
32
+
includeStats: props.includeStats,
33
33
+
}),
34
34
+
queryFn: () =>
35
35
+
getUrlMetadata({ url: props.url, includeStats: props.includeStats }),
12
36
});
13
13
-
return metadata;
14
37
}
+13
src/webapp/features/connections/lib/mutations/useCreateConnection.tsx
View file
Reviewed
···
2
2
import { createConnection } from '../dal';
3
3
import { connectionKeys } from '../connectionKeys';
4
4
import { ConnectionType } from '@semble/types';
5
5
+
import { cardKeys } from '@/features/cards/lib/cardKeys';
5
6
6
7
export default function useCreateConnection() {
7
8
const queryClient = useQueryClient();
···
28
29
// Invalidate backward connections for target URL
29
30
queryClient.invalidateQueries({
30
31
queryKey: connectionKeys.backwardForUrl(variables.targetUrl),
32
32
+
});
33
33
+
34
34
+
// Invalidate URL metadata with stats for both source and target URLs to update tab counts
35
35
+
queryClient.invalidateQueries({
36
36
+
queryKey: cardKeys.urlMetadata(variables.sourceUrl, {
37
37
+
includeStats: true,
38
38
+
}),
39
39
+
});
40
40
+
queryClient.invalidateQueries({
41
41
+
queryKey: cardKeys.urlMetadata(variables.targetUrl, {
42
42
+
includeStats: true,
43
43
+
}),
31
44
});
32
45
},
33
46
});
+14
src/webapp/features/connections/lib/mutations/useDeleteConnection.tsx
View file
Reviewed
···
14
14
onSuccess: () => {
15
15
// Invalidate all connection queries to ensure deletions are reflected everywhere
16
16
queryClient.invalidateQueries({ queryKey: connectionKeys.all() });
17
17
+
// Invalidate all URL metadata queries with stats to update tab counts
18
18
+
queryClient.invalidateQueries({
19
19
+
predicate: (query): boolean => {
20
20
+
const key = query.queryKey as unknown[];
21
21
+
return !!(
22
22
+
key[0] === 'cards' &&
23
23
+
key[1] === 'metadata' &&
24
24
+
key[3] &&
25
25
+
typeof key[3] === 'object' &&
26
26
+
'includeStats' in key[3] &&
27
27
+
key[3].includeStats === true
28
28
+
);
29
29
+
},
30
30
+
});
17
31
},
18
32
});
19
33
+14
src/webapp/features/connections/lib/mutations/useUpdateConnection.tsx
View file
Reviewed
···
14
14
onSuccess: () => {
15
15
// Invalidate all connection queries to ensure updates appear everywhere
16
16
queryClient.invalidateQueries({ queryKey: connectionKeys.all() });
17
17
+
// Invalidate all URL metadata queries with stats to update tab counts
18
18
+
queryClient.invalidateQueries({
19
19
+
predicate: (query): boolean => {
20
20
+
const key = query.queryKey as unknown[];
21
21
+
return !!(
22
22
+
key[0] === 'cards' &&
23
23
+
key[1] === 'metadata' &&
24
24
+
key[3] &&
25
25
+
typeof key[3] === 'object' &&
26
26
+
'includeStats' in key[3] &&
27
27
+
key[3].includeStats === true
28
28
+
);
29
29
+
},
30
30
+
});
17
31
},
18
32
});
19
33
+1
-1
src/webapp/features/graph/lib/queries/useGraphNodeUrl.tsx
View file
Reviewed
···
19
19
queryKey: url ? [url] : ['graph', 'url', 'metadata', 'empty'],
20
20
queryFn: () => {
21
21
if (!url) throw new Error('URL is required');
22
22
-
return getUrlMetadata(url);
22
22
+
return getUrlMetadata({ url });
23
23
},
24
24
enabled: enabled && !!url,
25
25
staleTime: 3 * 60 * 1000, // 3 minutes - URLs change more often
+1
-1
src/webapp/features/platforms/bluesky/components/blueskySemblePost/BlueskySemblePost.tsx
View file
Reviewed
···
39
39
export default async function BlueskySemblePost(props: Props) {
40
40
const postUri = getPostUriFromUrl(props.url);
41
41
const data = await getBlueskyPost(postUri);
42
42
-
const { metadata } = await getUrlMetadata(props.url);
42
42
+
const { metadata } = await getUrlMetadata({ url: props.url });
43
43
const urlTypeIcon = getUrlTypeIcon(metadata.type as UrlType);
44
44
const IconComponent = urlTypeIcon as IconType;
45
45
+22
-4
src/webapp/features/semble/components/sembleTabs/SembleTabs.tsx
View file
Reviewed
···
11
11
} from '@mantine/core';
12
12
import TabItem from './TabItem';
13
13
import { useFeatureFlags } from '@/lib/clientFeatureFlags';
14
14
+
import type { UrlAggregateStats } from '@semble/types';
15
15
+
import useUrlMetadata from '@/features/cards/lib/queries/useUrlMetadata';
14
16
15
17
import SembleNotesContainer from '../../containers/sembleNotesContainer/SembleNotesContainer';
16
18
import SembleNotesContainerSkeleton from '../../containers/sembleNotesContainer/Skeleton.SembleNotesContainer';
···
31
33
32
34
interface Props {
33
35
url: string;
36
36
+
stats?: UrlAggregateStats;
34
37
}
35
38
36
39
type TabValue = 'notes' | 'collections' | 'addedBy' | 'similar' | 'connections';
···
38
41
export default function SembleTabs(props: Props) {
39
42
const [activeTab, setActiveTab] = useState<TabValue>('similar');
40
43
const { data: featureFlags } = useFeatureFlags();
44
44
+
const { data: urlMetadata } = useUrlMetadata({
45
45
+
url: props.url,
46
46
+
includeStats: true,
47
47
+
initialData: props.stats ? { stats: props.stats } : undefined,
48
48
+
});
49
49
+
50
50
+
const stats = urlMetadata?.stats;
41
51
42
52
return (
43
53
<Tabs
···
50
60
<Group wrap="nowrap">
51
61
<TabItem value="similar">Similar cards</TabItem>
52
62
{featureFlags?.connections && (
53
53
-
<TabItem value="connections">Connections</TabItem>
63
63
+
<TabItem value="connections" count={stats?.connections.all.total}>
64
64
+
Connections
65
65
+
</TabItem>
54
66
)}
55
55
-
<TabItem value="notes">Notes</TabItem>
56
56
-
<TabItem value="collections">Collections</TabItem>
57
57
-
<TabItem value="addedBy">Added by</TabItem>
67
67
+
<TabItem value="notes" count={stats?.noteCount}>
68
68
+
Notes
69
69
+
</TabItem>
70
70
+
<TabItem value="collections" count={stats?.collectionCount}>
71
71
+
Collections
72
72
+
</TabItem>
73
73
+
<TabItem value="addedBy" count={stats?.libraryCount}>
74
74
+
Added by
75
75
+
</TabItem>
58
76
<TabItem value="mentions">Mentions</TabItem>
59
77
</Group>
60
78
</TabsList>
+8
-1
src/webapp/features/semble/components/sembleTabs/TabItem.tsx
View file
Reviewed
···
6
6
interface Props {
7
7
value: string;
8
8
children: string;
9
9
+
count?: number;
9
10
}
10
11
11
12
export default function TabItem(props: Props) {
13
13
+
// Display count if provided and greater than 0
14
14
+
const displayText =
15
15
+
props.count && props.count > 0
16
16
+
? `${props.children} (${props.count})`
17
17
+
: props.children;
18
18
+
12
19
return (
13
20
<TabsTab
14
21
value={props.value}
···
19
26
posthog.capture(`Semble: ${props.value} tab`);
20
27
}}
21
28
>
22
22
-
{props.children}
29
29
+
{displayText}
23
30
</TabsTab>
24
31
);
25
32
}
+4
-1
src/webapp/features/semble/components/urlMetadataHeader/UrlMetadataHeader.tsx
View file
Reviewed
···
26
26
}
27
27
28
28
export default async function UrlMetadataHeader(props: Props) {
29
29
-
const { metadata } = await getUrlMetadata(props.url);
29
29
+
const { metadata } = await getUrlMetadata({
30
30
+
url: props.url,
31
31
+
includeStats: true,
32
32
+
});
30
33
const urlTypeIcon = getUrlTypeIcon(metadata.type as UrlType);
31
34
const IconComponent = urlTypeIcon as IconType;
32
35
+3
-1
src/webapp/features/semble/containers/sembleContainer/SembleContainer.tsx
View file
Reviewed
···
7
7
import BlueskySembleHeader from '@/features/platforms/bluesky/container/blueskySembleHeader/BlueskySembleHeader';
8
8
import { detectUrlPlatform, SupportedPlatform } from '@/lib/utils/link';
9
9
import BlueskySembleHeaderSkeleton from '@/features/platforms/bluesky/container/blueskySembleHeader/Skeleton.BlueskySembleHeader';
10
10
+
import type { UrlAggregateStats } from '@semble/types';
10
11
11
12
interface Props {
12
13
url: string;
13
14
viaCardId?: string;
15
15
+
stats?: UrlAggregateStats;
14
16
}
15
17
16
18
export default function SembleContainer(props: Props) {
···
42
44
<SembleHeader url={props.url} viaCardId={props.viaCardId} />
43
45
</Suspense>
44
46
)}
45
45
-
<SembleTabs url={props.url} />
47
47
+
<SembleTabs url={props.url} stats={props.stats} />
46
48
</Stack>
47
49
</Container>
48
50
</Container>