tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
28
pulls
pipelines
WIP suspense reader pages
awarm.space
1 month ago
07da0858
c2d45121
+472
-36
12 changed files
expand all
collapse all
unified
split
app
(home-pages)
reader
FeedSkeleton.tsx
GlobalContent.tsx
InboxContent.tsx
NewContent.tsx
getHotFeed.ts
getNewFeed.ts
hot
page.tsx
layout.tsx
new
page.tsx
page.tsx
components
PageLayouts
DashboardLayout.tsx
Tab.tsx
+13
app/(home-pages)/reader/FeedSkeleton.tsx
···
1
1
+
export function FeedSkeleton() {
2
2
+
return (
3
3
+
<div className="flex flex-col gap-6 w-full animate-pulse">
4
4
+
{[...Array(3)].map((_, i) => (
5
5
+
<div key={i} className="flex flex-col gap-3 p-2">
6
6
+
<div className="h-4 bg-border-light rounded w-1/3" />
7
7
+
<div className="h-3 bg-border-light rounded w-2/3" />
8
8
+
<div className="h-3 bg-border-light rounded w-1/2" />
9
9
+
</div>
10
10
+
))}
11
11
+
</div>
12
12
+
);
13
13
+
}
+16
-5
app/(home-pages)/reader/GlobalContent.tsx
···
1
1
"use client";
2
2
+
import { use } from "react";
2
3
import useSWR from "swr";
3
4
import { callRPC } from "app/api/rpc/client";
4
5
import { PostListing } from "components/PostListing";
···
8
9
MobileInteractionPreviewDrawer,
9
10
} from "./InteractionDrawers";
10
11
11
11
-
export const GlobalContent = () => {
12
12
-
const { data, isLoading } = useSWR("hot_feed", async () => {
13
13
-
const res = await callRPC("get_hot_feed", {});
14
14
-
return res as unknown as { posts: Post[] };
15
15
-
});
12
12
+
export const GlobalContent = (props: {
13
13
+
promise: Promise<{ posts: Post[] }>;
14
14
+
}) => {
15
15
+
const initialData = use(props.promise);
16
16
+
17
17
+
const { data, isLoading } = useSWR(
18
18
+
"hot_feed",
19
19
+
async () => {
20
20
+
const res = await callRPC("get_hot_feed", {});
21
21
+
return res as unknown as { posts: Post[] };
22
22
+
},
23
23
+
{
24
24
+
fallbackData: { posts: initialData.posts },
25
25
+
},
26
26
+
);
16
27
17
28
const posts = data?.posts ?? [];
18
29
+5
-3
app/(home-pages)/reader/InboxContent.tsx
···
1
1
"use client";
2
2
+
import { use } from "react";
2
3
import { ButtonPrimary } from "components/Buttons";
3
4
import { DiscoverSmall } from "components/Icons/DiscoverSmall";
4
5
import type { Cursor, Post } from "./getReaderFeed";
···
14
15
} from "./InteractionDrawers";
15
16
16
17
export const InboxContent = (props: {
17
17
-
posts: Post[];
18
18
-
nextCursor: Cursor | null;
18
18
+
promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>;
19
19
}) => {
20
20
+
const { posts, nextCursor } = use(props.promise);
21
21
+
20
22
const getKey = (
21
23
pageIndex: number,
22
24
previousPageData: {
···
38
40
getKey,
39
41
([_, cursor]) => getReaderFeed(cursor),
40
42
{
41
41
-
fallbackData: [{ posts: props.posts, nextCursor: props.nextCursor }],
43
43
+
fallbackData: [{ posts, nextCursor }],
42
44
revalidateFirstPage: false,
43
45
},
44
46
);
+93
app/(home-pages)/reader/NewContent.tsx
···
1
1
+
"use client";
2
2
+
3
3
+
import { use } from "react";
4
4
+
import type { Cursor, Post } from "./getReaderFeed";
5
5
+
import useSWRInfinite from "swr/infinite";
6
6
+
import { getNewFeed } from "./getNewFeed";
7
7
+
import { useEffect, useRef } from "react";
8
8
+
import { PostListing } from "components/PostListing";
9
9
+
import {
10
10
+
DesktopInteractionPreviewDrawer,
11
11
+
MobileInteractionPreviewDrawer,
12
12
+
} from "./InteractionDrawers";
13
13
+
14
14
+
export const NewContent = (props: {
15
15
+
promise: Promise<{ posts: Post[]; nextCursor: Cursor | null }>;
16
16
+
}) => {
17
17
+
const { posts, nextCursor } = use(props.promise);
18
18
+
19
19
+
const getKey = (
20
20
+
pageIndex: number,
21
21
+
previousPageData: {
22
22
+
posts: Post[];
23
23
+
nextCursor: Cursor | null;
24
24
+
} | null,
25
25
+
) => {
26
26
+
if (previousPageData && !previousPageData.nextCursor) return null;
27
27
+
if (pageIndex === 0) return ["new-feed", null] as const;
28
28
+
return ["new-feed", previousPageData?.nextCursor] as const;
29
29
+
};
30
30
+
31
31
+
const { data, size, setSize, isValidating } = useSWRInfinite(
32
32
+
getKey,
33
33
+
([_, cursor]) => getNewFeed(cursor),
34
34
+
{
35
35
+
fallbackData: [{ posts, nextCursor }],
36
36
+
revalidateFirstPage: false,
37
37
+
},
38
38
+
);
39
39
+
40
40
+
const loadMoreRef = useRef<HTMLDivElement>(null);
41
41
+
42
42
+
useEffect(() => {
43
43
+
const observer = new IntersectionObserver(
44
44
+
(entries) => {
45
45
+
if (entries[0].isIntersecting && !isValidating) {
46
46
+
const hasMore = data && data[data.length - 1]?.nextCursor;
47
47
+
if (hasMore) {
48
48
+
setSize(size + 1);
49
49
+
}
50
50
+
}
51
51
+
},
52
52
+
{ threshold: 0.1 },
53
53
+
);
54
54
+
55
55
+
if (loadMoreRef.current) {
56
56
+
observer.observe(loadMoreRef.current);
57
57
+
}
58
58
+
59
59
+
return () => observer.disconnect();
60
60
+
}, [data, size, setSize, isValidating]);
61
61
+
62
62
+
const allPosts = data ? data.flatMap((page) => page.posts) : [];
63
63
+
64
64
+
if (allPosts.length === 0 && !isValidating) {
65
65
+
return (
66
66
+
<div className="flex flex-col gap-2 container bg-[rgba(var(--bg-page),.7)] sm:p-4 p-3 justify-between text-center text-tertiary">
67
67
+
No posts yet. Check back soon!
68
68
+
</div>
69
69
+
);
70
70
+
}
71
71
+
72
72
+
return (
73
73
+
<div className="flex flex-row gap-6 w-full">
74
74
+
<div className="flex flex-col gap-6 w-full relative">
75
75
+
{allPosts.map((p) => (
76
76
+
<PostListing {...p} key={p.documents.uri} />
77
77
+
))}
78
78
+
<div
79
79
+
ref={loadMoreRef}
80
80
+
className="absolute bottom-96 left-0 w-full h-px pointer-events-none"
81
81
+
aria-hidden="true"
82
82
+
/>
83
83
+
{isValidating && (
84
84
+
<div className="text-center text-tertiary py-4">
85
85
+
Loading more posts...
86
86
+
</div>
87
87
+
)}
88
88
+
</div>
89
89
+
<DesktopInteractionPreviewDrawer />
90
90
+
<MobileInteractionPreviewDrawer />
91
91
+
</div>
92
92
+
);
93
93
+
};
+138
app/(home-pages)/reader/getHotFeed.ts
···
1
1
+
"use server";
2
2
+
3
3
+
import { drizzle } from "drizzle-orm/node-postgres";
4
4
+
import { sql } from "drizzle-orm";
5
5
+
import { pool } from "supabase/pool";
6
6
+
import Client from "ioredis";
7
7
+
import { AtUri } from "@atproto/api";
8
8
+
import { idResolver } from "./idResolver";
9
9
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
10
10
+
import {
11
11
+
normalizeDocumentRecord,
12
12
+
normalizePublicationRecord,
13
13
+
} from "src/utils/normalizeRecords";
14
14
+
import { supabaseServerClient } from "supabase/serverClient";
15
15
+
import type { Post } from "./getReaderFeed";
16
16
+
17
17
+
let redisClient: Client | null = null;
18
18
+
if (process.env.REDIS_URL && process.env.NODE_ENV === "production") {
19
19
+
redisClient = new Client(process.env.REDIS_URL);
20
20
+
}
21
21
+
22
22
+
const CACHE_KEY = "hot_feed_v1";
23
23
+
const CACHE_TTL = 300; // 5 minutes
24
24
+
25
25
+
export async function getHotFeed(): Promise<{ posts: Post[] }> {
26
26
+
// Check Redis cache
27
27
+
if (redisClient) {
28
28
+
const cached = await redisClient.get(CACHE_KEY);
29
29
+
if (cached) {
30
30
+
return JSON.parse(cached) as { posts: Post[] };
31
31
+
}
32
32
+
}
33
33
+
34
34
+
// Run ranked SQL query to get top 50 URIs
35
35
+
const client = await pool.connect();
36
36
+
const db = drizzle(client);
37
37
+
38
38
+
let uris: string[];
39
39
+
try {
40
40
+
const ranked = await db.execute(sql`
41
41
+
SELECT uri
42
42
+
FROM documents
43
43
+
WHERE indexed = true
44
44
+
AND sort_date > now() - interval '7 days'
45
45
+
ORDER BY
46
46
+
(bsky_like_count + recommend_count * 5)::numeric
47
47
+
/ power(extract(epoch from (now() - sort_date)) / 3600 + 2, 1.5) DESC
48
48
+
LIMIT 50
49
49
+
`);
50
50
+
uris = ranked.rows.map((row: any) => row.uri as string);
51
51
+
} finally {
52
52
+
client.release();
53
53
+
}
54
54
+
55
55
+
if (uris.length === 0) {
56
56
+
return { posts: [] };
57
57
+
}
58
58
+
59
59
+
// Batch-fetch documents with publication joins and interaction counts
60
60
+
const { data: documents } = await supabaseServerClient
61
61
+
.from("documents")
62
62
+
.select(
63
63
+
`*,
64
64
+
comments_on_documents(count),
65
65
+
document_mentions_in_bsky(count),
66
66
+
recommends_on_documents(count),
67
67
+
documents_in_publications(publications(*))`,
68
68
+
)
69
69
+
.in("uri", uris);
70
70
+
71
71
+
// Build lookup map for enrichment
72
72
+
const docMap = new Map((documents || []).map((d) => [d.uri, d]));
73
73
+
74
74
+
// Process in ranked order, deduplicating by identity key (DID/rkey)
75
75
+
const seen = new Set<string>();
76
76
+
const orderedDocs: NonNullable<typeof documents>[number][] = [];
77
77
+
for (const uri of uris) {
78
78
+
try {
79
79
+
const parsed = new AtUri(uri);
80
80
+
const identityKey = `${parsed.host}/${parsed.rkey}`;
81
81
+
if (seen.has(identityKey)) continue;
82
82
+
seen.add(identityKey);
83
83
+
} catch {
84
84
+
// invalid URI, skip dedup check
85
85
+
}
86
86
+
const doc = docMap.get(uri);
87
87
+
if (doc) orderedDocs.push(doc);
88
88
+
}
89
89
+
90
90
+
// Enrich into Post[]
91
91
+
const posts = (
92
92
+
await Promise.all(
93
93
+
orderedDocs.map(async (doc) => {
94
94
+
const pub = doc.documents_in_publications?.[0]?.publications;
95
95
+
const uri = new AtUri(doc.uri);
96
96
+
const handle = await idResolver.did.resolve(uri.host);
97
97
+
98
98
+
const normalizedData = normalizeDocumentRecord(doc.data, doc.uri);
99
99
+
if (!normalizedData) return null;
100
100
+
101
101
+
const normalizedPubRecord = pub
102
102
+
? normalizePublicationRecord(pub.record)
103
103
+
: null;
104
104
+
105
105
+
const post: Post = {
106
106
+
publication: pub
107
107
+
? {
108
108
+
href: getPublicationURL(pub),
109
109
+
pubRecord: normalizedPubRecord,
110
110
+
uri: pub.uri || "",
111
111
+
}
112
112
+
: undefined,
113
113
+
author: handle?.alsoKnownAs?.[0]
114
114
+
? `@${handle.alsoKnownAs[0].slice(5)}`
115
115
+
: null,
116
116
+
documents: {
117
117
+
comments_on_documents: doc.comments_on_documents,
118
118
+
document_mentions_in_bsky: doc.document_mentions_in_bsky,
119
119
+
recommends_on_documents: doc.recommends_on_documents,
120
120
+
data: normalizedData,
121
121
+
uri: doc.uri,
122
122
+
sort_date: doc.sort_date,
123
123
+
},
124
124
+
};
125
125
+
return post;
126
126
+
}),
127
127
+
)
128
128
+
).filter((post): post is Post => post !== null);
129
129
+
130
130
+
const response = { posts };
131
131
+
132
132
+
// Cache in Redis
133
133
+
if (redisClient) {
134
134
+
await redisClient.setex(CACHE_KEY, CACHE_TTL, JSON.stringify(response));
135
135
+
}
136
136
+
137
137
+
return response;
138
138
+
}
+91
app/(home-pages)/reader/getNewFeed.ts
···
1
1
+
"use server";
2
2
+
3
3
+
import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
4
4
+
import { supabaseServerClient } from "supabase/serverClient";
5
5
+
import { AtUri } from "@atproto/api";
6
6
+
import { idResolver } from "./idResolver";
7
7
+
import {
8
8
+
normalizeDocumentRecord,
9
9
+
normalizePublicationRecord,
10
10
+
} from "src/utils/normalizeRecords";
11
11
+
import { deduplicateByUriOrdered } from "src/utils/deduplicateRecords";
12
12
+
import type { Cursor, Post } from "./getReaderFeed";
13
13
+
14
14
+
export async function getNewFeed(
15
15
+
cursor?: Cursor | null,
16
16
+
): Promise<{ posts: Post[]; nextCursor: Cursor | null }> {
17
17
+
let query = supabaseServerClient
18
18
+
.from("documents")
19
19
+
.select(
20
20
+
`*,
21
21
+
comments_on_documents(count),
22
22
+
document_mentions_in_bsky(count),
23
23
+
recommends_on_documents(count),
24
24
+
documents_in_publications!inner(publications!inner(*))`,
25
25
+
)
26
26
+
.or(
27
27
+
"record->preferences->showInDiscover.is.null,record->preferences->>showInDiscover.eq.true",
28
28
+
{ referencedTable: "documents_in_publications.publications" },
29
29
+
)
30
30
+
.order("sort_date", { ascending: false })
31
31
+
.order("uri", { ascending: false })
32
32
+
.limit(25);
33
33
+
34
34
+
if (cursor) {
35
35
+
query = query.or(
36
36
+
`sort_date.lt.${cursor.timestamp},and(sort_date.eq.${cursor.timestamp},uri.lt.${cursor.uri})`,
37
37
+
);
38
38
+
}
39
39
+
40
40
+
let { data: rawFeed, error } = await query;
41
41
+
42
42
+
const feed = deduplicateByUriOrdered(rawFeed || []);
43
43
+
44
44
+
let posts = (
45
45
+
await Promise.all(
46
46
+
feed.map(async (post) => {
47
47
+
let pub = post.documents_in_publications[0]?.publications!;
48
48
+
let uri = new AtUri(post.uri);
49
49
+
let handle = await idResolver.did.resolve(uri.host);
50
50
+
51
51
+
const normalizedData = normalizeDocumentRecord(post.data, post.uri);
52
52
+
if (!normalizedData) return null;
53
53
+
54
54
+
const normalizedPubRecord = normalizePublicationRecord(pub?.record);
55
55
+
56
56
+
let p: Post = {
57
57
+
publication: {
58
58
+
href: getPublicationURL(pub),
59
59
+
pubRecord: normalizedPubRecord,
60
60
+
uri: pub?.uri || "",
61
61
+
},
62
62
+
author: handle?.alsoKnownAs?.[0]
63
63
+
? `@${handle.alsoKnownAs[0].slice(5)}`
64
64
+
: null,
65
65
+
documents: {
66
66
+
comments_on_documents: post.comments_on_documents,
67
67
+
document_mentions_in_bsky: post.document_mentions_in_bsky,
68
68
+
recommends_on_documents: post.recommends_on_documents,
69
69
+
data: normalizedData,
70
70
+
uri: post.uri,
71
71
+
sort_date: post.sort_date,
72
72
+
},
73
73
+
};
74
74
+
return p;
75
75
+
}) || [],
76
76
+
)
77
77
+
).filter((post): post is Post => post !== null);
78
78
+
79
79
+
const nextCursor =
80
80
+
posts.length > 0
81
81
+
? {
82
82
+
timestamp: posts[posts.length - 1].documents.sort_date,
83
83
+
uri: posts[posts.length - 1].documents.uri,
84
84
+
}
85
85
+
: null;
86
86
+
87
87
+
return {
88
88
+
posts,
89
89
+
nextCursor,
90
90
+
};
91
91
+
}
+13
app/(home-pages)/reader/hot/page.tsx
···
1
1
+
import { Suspense } from "react";
2
2
+
import { getHotFeed } from "../getHotFeed";
3
3
+
import { GlobalContent } from "../GlobalContent";
4
4
+
import { FeedSkeleton } from "../FeedSkeleton";
5
5
+
6
6
+
export default async function HotPage() {
7
7
+
const feedPromise = getHotFeed();
8
8
+
return (
9
9
+
<Suspense fallback={<FeedSkeleton />}>
10
10
+
<GlobalContent promise={feedPromise} />
11
11
+
</Suspense>
12
12
+
);
13
13
+
}
+74
app/(home-pages)/reader/layout.tsx
···
1
1
+
"use client";
2
2
+
3
3
+
import { usePathname } from "next/navigation";
4
4
+
import Link from "next/link";
5
5
+
import { Header } from "components/PageHeader";
6
6
+
import { Footer } from "components/ActionBar/Footer";
7
7
+
import { DesktopNavigation } from "components/ActionBar/DesktopNavigation";
8
8
+
import { MobileNavigation } from "components/ActionBar/MobileNavigation";
9
9
+
import { MediaContents } from "components/Media";
10
10
+
import { DashboardIdContext } from "components/PageLayouts/DashboardLayout";
11
11
+
12
12
+
const tabs = [
13
13
+
{ name: "Subs", href: "/reader" },
14
14
+
{ name: "What's Hot", href: "/reader/hot" },
15
15
+
{ name: "New", href: "/reader/new" },
16
16
+
];
17
17
+
18
18
+
export default function ReaderLayout({
19
19
+
children,
20
20
+
}: {
21
21
+
children: React.ReactNode;
22
22
+
}) {
23
23
+
const pathname = usePathname();
24
24
+
25
25
+
const isActive = (href: string) => {
26
26
+
if (href === "/reader") return pathname === "/reader";
27
27
+
return pathname.startsWith(href);
28
28
+
};
29
29
+
30
30
+
return (
31
31
+
<DashboardIdContext.Provider value="reader">
32
32
+
<div className="dashboard pwa-padding relative max-w-(--breakpoint-lg) w-full h-full mx-auto flex sm:flex-row flex-col sm:items-stretch sm:px-6">
33
33
+
<MediaContents mobile={false}>
34
34
+
<div className="flex flex-col gap-3 my-6">
35
35
+
<DesktopNavigation currentPage="reader" />
36
36
+
</div>
37
37
+
</MediaContents>
38
38
+
<div
39
39
+
className="w-full h-full flex flex-col gap-2 relative overflow-y-scroll pt-3 pb-3 px-3 sm:pt-8 sm:pb-3 sm:pl-6 sm:pr-4"
40
40
+
id="home-content"
41
41
+
>
42
42
+
<Header>
43
43
+
<div className="pubDashTabs flex flex-row gap-1">
44
44
+
{tabs.map((tab) => (
45
45
+
<Link key={tab.name} href={tab.href}>
46
46
+
<div
47
47
+
className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${
48
48
+
isActive(tab.href)
49
49
+
? "text-accent-2 bg-accent-1 font-bold -mb-px"
50
50
+
: "text-tertiary"
51
51
+
}`}
52
52
+
>
53
53
+
{tab.name}
54
54
+
</div>
55
55
+
</Link>
56
56
+
))}
57
57
+
</div>
58
58
+
<div className="sm:block grow">
59
59
+
{pathname === "/reader" && (
60
60
+
<div className="place-self-end text text-tertiary text-sm">
61
61
+
Publications
62
62
+
</div>
63
63
+
)}
64
64
+
</div>
65
65
+
</Header>
66
66
+
{children}
67
67
+
</div>
68
68
+
<Footer>
69
69
+
<MobileNavigation currentPage="reader" />
70
70
+
</Footer>
71
71
+
</div>
72
72
+
</DashboardIdContext.Provider>
73
73
+
);
74
74
+
}
+13
app/(home-pages)/reader/new/page.tsx
···
1
1
+
import { Suspense } from "react";
2
2
+
import { getNewFeed } from "../getNewFeed";
3
3
+
import { NewContent } from "../NewContent";
4
4
+
import { FeedSkeleton } from "../FeedSkeleton";
5
5
+
6
6
+
export default async function NewPage() {
7
7
+
const feedPromise = getNewFeed();
8
8
+
return (
9
9
+
<Suspense fallback={<FeedSkeleton />}>
10
10
+
<NewContent promise={feedPromise} />
11
11
+
</Suspense>
12
12
+
);
13
13
+
}
+8
-27
app/(home-pages)/reader/page.tsx
···
1
1
-
import { DashboardLayout } from "components/PageLayouts/DashboardLayout";
1
1
+
import { Suspense } from "react";
2
2
+
import { getReaderFeed } from "./getReaderFeed";
2
3
import { InboxContent } from "./InboxContent";
3
3
-
import { GlobalContent } from "./GlobalContent";
4
4
-
import { getReaderFeed } from "./getReaderFeed";
4
4
+
import { FeedSkeleton } from "./FeedSkeleton";
5
5
6
6
-
export default async function Reader(props: {}) {
7
7
-
let posts = await getReaderFeed();
6
6
+
export default async function Reader() {
7
7
+
const feedPromise = getReaderFeed();
8
8
return (
9
9
-
<DashboardLayout
10
10
-
id="reader"
11
11
-
currentPage="reader"
12
12
-
defaultTab="Subs"
13
13
-
actions={null}
14
14
-
tabs={{
15
15
-
Subs: {
16
16
-
controls: (
17
17
-
<div className="place-self-end text text-tertiary text-sm">
18
18
-
Publications
19
19
-
</div>
20
20
-
),
21
21
-
content: (
22
22
-
<InboxContent nextCursor={posts.nextCursor} posts={posts.posts} />
23
23
-
),
24
24
-
},
25
25
-
Global: {
26
26
-
controls: null,
27
27
-
content: <GlobalContent />,
28
28
-
},
29
29
-
}}
30
30
-
/>
9
9
+
<Suspense fallback={<FeedSkeleton />}>
10
10
+
<InboxContent promise={feedPromise} />
11
11
+
</Suspense>
31
12
);
32
13
}
+4
-1
components/PageLayouts/DashboardLayout.tsx
···
71
71
},
72
72
}));
73
73
74
74
-
const DashboardIdContext = createContext<string | null>(null);
74
74
+
export const DashboardIdContext = createContext<string | null>(null);
75
75
76
76
export const useDashboardId = () => {
77
77
const id = useContext(DashboardIdContext);
···
143
143
profileDid?: string;
144
144
actions?: React.ReactNode;
145
145
pageTitle?: string;
146
146
+
onTabHover?: (tabName: string) => void;
146
147
}) {
147
148
const searchParams = useSearchParams();
148
149
const tabParam = searchParams.get("tab");
···
207
208
name={t}
208
209
selected={t === tab}
209
210
onSelect={() => setTabWithUrl(t)}
211
211
+
onMouseEnter={() => props.onTabHover?.(t)}
212
212
+
onPointerDown={() => props.onTabHover?.(t)}
210
213
/>
211
214
);
212
215
})}
+4
components/Tab.tsx
···
4
4
name: string;
5
5
selected: boolean;
6
6
onSelect: () => void;
7
7
+
onMouseEnter?: () => void;
8
8
+
onPointerDown?: () => void;
7
9
href?: string;
8
10
}) => {
9
11
return (
10
12
<div
11
13
className={`pubTabs px-1 py-0 flex gap-1 items-center rounded-md hover:cursor-pointer ${props.selected ? "text-accent-2 bg-accent-1 font-bold -mb-px" : "text-tertiary"}`}
12
14
onClick={() => props.onSelect()}
15
15
+
onMouseEnter={props.onMouseEnter}
16
16
+
onPointerDown={props.onPointerDown}
13
17
>
14
18
{props.name}
15
19
{props.href && <ExternalLinkTiny />}