Skip to content

Commit 01d9683

Browse files
tsahimatsliahclaude
andcommitted
feat(explore): full-width topic content + polished entity cards
Dramatically improve the /explore/[tag] page: - Content (recommended stories, who-to-follow, top sources, feeds) now spans the full content width instead of being squeezed in a narrow column; only the identity header stays centered & readable. - Widen the description paragraph so it wraps over fewer lines. - New shared ExploreEntityCard (avatar, name, @handle, bio, full-width Follow button at the bottom) used for BOTH "Who to follow" (users) and "Top sources covering it" (sources), laid out in a responsive grid with skeletons. - Followers stat remains in the header (renders once the backend field lands). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 04d51b8 commit 01d9683

2 files changed

Lines changed: 177 additions & 46 deletions

File tree

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import type { ReactElement } from 'react';
2+
import React, { useContext } from 'react';
3+
import classNames from 'classnames';
4+
import Link from '../utilities/Link';
5+
import { Image } from '../image/Image';
6+
import { fallbackImages } from '../../lib/config';
7+
import { FollowButton } from '../contentPreference/FollowButton';
8+
import { ContentPreferenceType } from '../../graphql/contentPreference';
9+
import { useContentPreferenceStatusQuery } from '../../hooks/contentPreference/useContentPreferenceStatusQuery';
10+
import AuthContext from '../../contexts/AuthContext';
11+
import { ButtonVariant } from '../buttons/Button';
12+
import {
13+
Typography,
14+
TypographyColor,
15+
TypographyTag,
16+
TypographyType,
17+
} from '../typography/Typography';
18+
19+
export interface ExploreEntityCardProps {
20+
entityId: string;
21+
entityType: ContentPreferenceType;
22+
entityName: string;
23+
name: string;
24+
image?: string;
25+
handle?: string;
26+
bio?: string;
27+
permalink: string;
28+
}
29+
30+
// A single card shared by "Who to follow" (users) and "Top sources" (sources):
31+
// avatar + name + handle + bio, with a full-width Follow button pinned to the
32+
// bottom so every card lines up regardless of bio length.
33+
export function ExploreEntityCard({
34+
entityId,
35+
entityType,
36+
entityName,
37+
name,
38+
image,
39+
handle,
40+
bio,
41+
permalink,
42+
}: ExploreEntityCardProps): ReactElement {
43+
const { user: loggedUser } = useContext(AuthContext);
44+
const { data: contentPreference } = useContentPreferenceStatusQuery({
45+
id: entityId,
46+
entity: entityType,
47+
});
48+
const isUser = entityType === ContentPreferenceType.User;
49+
const isSelf = isUser && loggedUser?.id === entityId;
50+
51+
return (
52+
<article className="flex h-full w-full flex-col gap-3 rounded-16 border border-border-subtlest-tertiary p-4">
53+
<Link href={permalink} passHref prefetch={false}>
54+
<a className="flex items-center gap-3 no-underline">
55+
<Image
56+
src={image || fallbackImages.avatar}
57+
alt={isUser ? `${name}'s avatar` : `${name} logo`}
58+
className={classNames(
59+
'size-12 shrink-0 object-cover',
60+
isUser ? 'rounded-full' : 'rounded-12',
61+
)}
62+
/>
63+
<span className="flex min-w-0 flex-col">
64+
<Typography
65+
tag={TypographyTag.Span}
66+
type={TypographyType.Body}
67+
color={TypographyColor.Primary}
68+
bold
69+
truncate
70+
>
71+
{name}
72+
</Typography>
73+
{handle && (
74+
<Typography
75+
tag={TypographyTag.Span}
76+
type={TypographyType.Footnote}
77+
color={TypographyColor.Tertiary}
78+
truncate
79+
>
80+
@{handle}
81+
</Typography>
82+
)}
83+
</span>
84+
</a>
85+
</Link>
86+
{bio && (
87+
<Typography
88+
type={TypographyType.Footnote}
89+
color={TypographyColor.Secondary}
90+
className="line-clamp-2"
91+
>
92+
{bio}
93+
</Typography>
94+
)}
95+
{!isSelf && (
96+
<FollowButton
97+
className="mt-auto w-full"
98+
buttonClassName="w-full justify-center"
99+
entityId={entityId}
100+
entityName={entityName}
101+
status={contentPreference?.status}
102+
type={entityType}
103+
variant={ButtonVariant.Primary}
104+
followedVariant={ButtonVariant.Secondary}
105+
showSubscribe={false}
106+
alwaysShow
107+
/>
108+
)}
109+
</article>
110+
);
111+
}

packages/shared/src/components/explore/ExploreTopicPage.tsx

Lines changed: 66 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,8 @@ import { ContentPreferenceType } from '../../graphql/contentPreference';
5050
import { TOP_CREATORS_BY_TAG_QUERY } from '../../graphql/users';
5151
import type { UserShortProfile } from '../../lib/user';
5252
import { SponsoredTagHero } from '../brand/SponsoredTagHero';
53-
import { RelatedEntities } from '../RelatedEntities';
5453
import { ElementPlaceholder } from '../ElementPlaceholder';
55-
import UserEntityCard from '../cards/entity/UserEntityCard';
54+
import { ExploreEntityCard } from './ExploreEntityCard';
5655
import { largeNumberFormat } from '../../lib';
5756
import { getExploreTagPageLink } from '../../lib/links';
5857
import { webappUrl } from '../../lib/constants';
@@ -156,6 +155,21 @@ const RelatedTagsBar = ({
156155
);
157156
};
158157

158+
const ENTITY_GRID =
159+
'grid grid-cols-1 gap-4 mobileL:grid-cols-2 laptop:grid-cols-3';
160+
161+
const EntityGridSkeleton = (): ReactElement => (
162+
<div className={ENTITY_GRID}>
163+
{Array.from({ length: 3 }).map((_, index) => (
164+
<ElementPlaceholder
165+
// eslint-disable-next-line react/no-array-index-key
166+
key={index}
167+
className="h-40 w-full rounded-16"
168+
/>
169+
))}
170+
</div>
171+
);
172+
159173
const TagTopSources = ({ tag }: { tag: string }): ReactElement | null => {
160174
const { data: topSources, isPending } = useQuery({
161175
queryKey: [RequestKey.SourceByTag, null, tag],
@@ -168,24 +182,35 @@ const TagTopSources = ({ tag }: { tag: string }): ReactElement | null => {
168182
staleTime: StaleTime.OneHour,
169183
});
170184

171-
const sources = topSources?.sourcesByTag?.edges?.map((edge) => edge.node);
172-
if (!sources || sources.length === 0) {
185+
const sources =
186+
topSources?.sourcesByTag?.edges?.map((edge) => edge.node) ?? [];
187+
if (!isPending && sources.length === 0) {
173188
return null;
174189
}
175190

176191
return (
177-
<RelatedEntities
178-
isLoading={isPending}
179-
items={sources.map((source) => ({
180-
id: source.id,
181-
image: source.image,
182-
imageAlt: `${source.name} logo`,
183-
name: source.name,
184-
permalink: source.permalink,
185-
}))}
186-
title="🔔 Top sources covering it"
187-
className="mx-4"
188-
/>
192+
<section className="mb-10">
193+
<SectionHeading>Top sources covering it</SectionHeading>
194+
{isPending ? (
195+
<EntityGridSkeleton />
196+
) : (
197+
<div className={ENTITY_GRID}>
198+
{sources.map((source) => (
199+
<ExploreEntityCard
200+
key={source.id}
201+
entityId={source.id ?? ''}
202+
entityType={ContentPreferenceType.Source}
203+
entityName={source.handle || source.name || ''}
204+
name={source.name ?? ''}
205+
image={source.image}
206+
handle={source.handle}
207+
bio={source.description}
208+
permalink={source.permalink ?? ''}
209+
/>
210+
))}
211+
</div>
212+
)}
213+
</section>
189214
);
190215
};
191216

@@ -215,32 +240,27 @@ const WhoToFollow = ({
215240
}
216241

217242
return (
218-
<section className="mx-4 mb-10 flex flex-col gap-3">
219-
<Typography
220-
tag={TypographyTag.H2}
221-
type={TypographyType.Title3}
222-
color={TypographyColor.Primary}
223-
bold
224-
>
225-
Who to follow
226-
</Typography>
227-
<div className="no-scrollbar flex gap-4 overflow-x-auto">
228-
{isLoading
229-
? Array.from({ length: 3 }).map((_, index) => (
230-
<ElementPlaceholder
231-
// eslint-disable-next-line react/no-array-index-key
232-
key={index}
233-
className="h-40 w-80 shrink-0 rounded-16"
234-
/>
235-
))
236-
: users.map((user) => (
237-
<UserEntityCard
238-
key={user.id}
239-
user={user}
240-
className={{ container: 'shrink-0' }}
241-
/>
242-
))}
243-
</div>
243+
<section className="mb-10">
244+
<SectionHeading>Who to follow</SectionHeading>
245+
{isLoading ? (
246+
<EntityGridSkeleton />
247+
) : (
248+
<div className={ENTITY_GRID}>
249+
{users.map((user) => (
250+
<ExploreEntityCard
251+
key={user.id}
252+
entityId={user.id}
253+
entityType={ContentPreferenceType.User}
254+
entityName={user.username || user.name || ''}
255+
name={user.name ?? ''}
256+
image={user.image}
257+
handle={user.username}
258+
bio={user.bio}
259+
permalink={user.permalink ?? ''}
260+
/>
261+
))}
262+
</div>
263+
)}
244264
</section>
245265
);
246266
};
@@ -350,11 +370,11 @@ export const ExploreTopicPage = ({
350370
/>
351371
</Head>
352372
)}
353-
<div className="mx-auto flex w-full max-w-screen-laptop flex-col px-4 py-6">
373+
<div className="flex w-full flex-col px-4 py-6">
354374
<RelatedTagsBar tag={tag} tags={recommendedTags} />
355375

356-
{/* Identity header — centered, editorial. */}
357-
<header className="mx-auto flex w-full max-w-screen-tablet flex-col items-center gap-4 py-8 text-center">
376+
{/* Identity header — centered & readable; content below spans full width. */}
377+
<header className="mx-auto flex w-full max-w-[48rem] flex-col items-center gap-4 py-8 text-center">
358378
<SponsoredTagHero tag={tag} />
359379
<Typography
360380
tag={TypographyTag.H1}
@@ -383,7 +403,7 @@ export const ExploreTopicPage = ({
383403
type={TypographyType.Body}
384404
color={TypographyColor.Secondary}
385405
center
386-
className="max-w-[34rem]"
406+
className="max-w-[44rem]"
387407
>
388408
{initialData.flags.description}
389409
</Typography>

0 commit comments

Comments
 (0)