Skip to content

Commit 29090ae

Browse files
committed
profile timeline
1 parent de60671 commit 29090ae

File tree

13 files changed

+191
-6
lines changed

13 files changed

+191
-6
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use client';
2+
3+
import { useRouter } from 'next/navigation';
4+
import { FC } from 'react';
5+
6+
import { TableRow } from '@/components/ui/table';
7+
8+
type ClickableRowProps = {
9+
href: string;
10+
children: React.ReactNode;
11+
className?: string;
12+
};
13+
14+
export const ClickableRow: FC<ClickableRowProps> = ({ children, href, className }) => {
15+
const router = useRouter();
16+
17+
const onClick = () => {
18+
router.push(href);
19+
};
20+
21+
return (
22+
<TableRow className={className} onClick={onClick}>
23+
{children}
24+
</TableRow>
25+
);
26+
};

app/by/[rankingType]/page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { graphqlRequest } from '@/lib/graphql-request';
1515
import { RankingsDocument, RankOrder } from '@/types/generated/graphql';
1616
import { getInitials } from '@/utils/get-initials';
1717

18+
import { ClickableRow } from './components/clickale-row';
19+
1820
const ITEMS_PER_PAGE = 100;
1921

2022
function getConfigByRankingType(rankingType: string) {
@@ -85,7 +87,7 @@ export default async function GlobalRanking({
8587
{data.rankings.map((item) => {
8688
const { githubId, user } = item;
8789
return (
88-
<TableRow key={githubId} className="border-b-0">
90+
<ClickableRow key={githubId} className="border-b-0" href={`/profile/${user?.login}`}>
8991
<TableCell className="font-medium">
9092
<div className="flex items-end gap-1">
9193
{item[rankPropName]}
@@ -103,7 +105,7 @@ export default async function GlobalRanking({
103105
</TableCell>
104106
<TableCell className="hidden sm:table-cell break-all whitespace-normal">{user?.location}</TableCell>
105107
<TableCell className="text-right">{user?.[rankPropName]?.toLocaleString('en-US')}</TableCell>
106-
</TableRow>
108+
</ClickableRow>
107109
);
108110
})}
109111
</TableBody>

app/components/search-profiile.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export const SearchProfile = () => {
5858
value={login}
5959
onChange={(event) => setLogin(event.target.value)}
6060
disabled={loading}
61+
autoCapitalize="none"
6162
/>
6263
<Button onClick={onSearch} disabled={loading} className="w-[96px]">
6364
{loading ? <ClipLoader loading={loading} size={16} /> : <Search className="size-4" />}

app/profile/[login]/components/profile-card.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ type ProfileCardProps = {
99

1010
export const ProfileCard: FC<ProfileCardProps> = ({ title, children }) => {
1111
return (
12-
<Card className="border-0 md:border-2 border-border p-0 md:p-4 min-w-xs flex-grow basis-0">
12+
<Card className="border-0 md:border-2 border-border p-0 md:p-4 min-w-xs flex-grow basis-0 shadow-none md:shadow-sm">
1313
<CardContent className="p-0 flex flex-col gap-1.5 md:gap-4">
1414
<h4 className="text-lg font-semibold">{title}</h4>
1515
<div className="flex flex-col gap-1.5">{children}</div>
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { compareDesc, parseISO } from 'date-fns';
2+
import { ArrowRight } from 'lucide-react';
3+
import { FC } from 'react';
4+
5+
import { Timeline, TimelineDescription, TimelineItem, TimelineTime } from '@/components/timeline/timeline';
6+
import {
7+
ChangeItemType,
8+
ChangeSetItemType,
9+
SocialAccountChangeItem,
10+
TimelineItemType,
11+
} from '@/types/profile-timeline.types';
12+
import { isObject } from '@/utils/is-object';
13+
import { splitCamelCase } from '@/utils/split-camelcase';
14+
15+
type ProfileTimelineProps = {
16+
timeline: TimelineItemType[] | null | undefined;
17+
firstSeenAt?: string;
18+
};
19+
20+
type ProfileTimelineDescriptionProps = {
21+
type: string;
22+
changeset: ChangeSetItemType;
23+
};
24+
25+
const parseChangesetItem = (changesetItem: ChangeItemType) => {
26+
if (isObject<NonNullable<SocialAccountChangeItem>>(changesetItem)) {
27+
if (!changesetItem.totalCount) {
28+
return null;
29+
}
30+
31+
return changesetItem.nodes
32+
?.map((account) => `${account.provider?.toLowerCase()}: ${account.displayName}`)
33+
.join('; ');
34+
}
35+
36+
if (typeof changesetItem === 'number') {
37+
return changesetItem.toLocaleString('en-US');
38+
}
39+
40+
if (!changesetItem) {
41+
return null;
42+
}
43+
44+
return String(changesetItem);
45+
};
46+
47+
const ProfileTimelineDescription: FC<ProfileTimelineDescriptionProps> = ({ type, changeset }) => {
48+
const before = parseChangesetItem(changeset.b);
49+
const after = parseChangesetItem(changeset.a);
50+
51+
return (
52+
<div className="flex items-center gap-2">
53+
{splitCamelCase(type)}:{' '}
54+
{!!before && (
55+
<span className="opacity-50">
56+
{before} {!after && '(removed)'}
57+
</span>
58+
)}{' '}
59+
{!!before && !!after && <ArrowRight size={12} />} {after}
60+
</div>
61+
);
62+
};
63+
64+
export const ProfileTimeline: FC<ProfileTimelineProps> = ({ timeline, firstSeenAt }) => {
65+
if (!timeline?.length) {
66+
return null;
67+
}
68+
69+
const sortedTimeline = timeline.sort((a, b) => compareDesc(parseISO(a.createdAt), parseISO(b.createdAt)));
70+
71+
return (
72+
<div className="flex flex-col gap-6">
73+
<h2 className="text-xl font-semibold">Timeline</h2>
74+
<Timeline>
75+
{sortedTimeline.map((item) => (
76+
<TimelineItem key={item.createdAt}>
77+
<TimelineTime isoDate={item.createdAt} />
78+
<TimelineDescription>
79+
{Object.entries(item.changes)
80+
.filter(([, changeset]) => !!changeset.a || !!changeset.b)
81+
.map(([type, changeset]) => (
82+
<ProfileTimelineDescription key={type} type={type} changeset={changeset} />
83+
))}
84+
</TimelineDescription>
85+
</TimelineItem>
86+
))}
87+
{!!firstSeenAt && (
88+
<TimelineItem>
89+
<TimelineTime isoDate={firstSeenAt} />
90+
<TimelineDescription>First seen</TimelineDescription>
91+
</TimelineItem>
92+
)}
93+
</Timeline>
94+
</div>
95+
);
96+
};

app/profile/[login]/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { graphqlRequest } from '@/lib/graphql-request';
2323
import { ProfileForMetadataDocument, UserDocument } from '@/types/generated/graphql';
2424

2525
import { ProfileListItem } from './components/profile-list-item';
26+
import { ProfileTimeline } from './components/profile-timeline';
2627
import { RanksOverview } from './components/ranks-overview';
2728
import { RepositoriesOverview } from './components/repositories-overiview';
2829
import { getSocialIcon } from './utils/get-social-icon';
@@ -149,7 +150,9 @@ export default async function Profile({ params }: { params: Promise<{ login: str
149150
contributedStars={user.contributedStars}
150151
/>
151152
</div>
152-
<div>Feed</div>
153+
<div>
154+
<ProfileTimeline timeline={user.timeline} firstSeenAt={user.firstSeenAt} />
155+
</div>
153156
</div>
154157
</Page>
155158
);

app/profile/[login]/profile-summary.gql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ query User($login: String!) {
3737
name
3838
twitterUsername
3939
websiteUrl
40+
firstSeenAt
4041
socialAccounts {
4142
totalCount
4243
nodes {

components/timeline/timeline.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { format, parseISO } from 'date-fns';
2+
3+
import { cn } from '@/lib/utils';
4+
5+
export const Timeline = ({ children }: { children: React.ReactNode }) => {
6+
return <ol className="relative border-s border-border">{children}</ol>;
7+
};
8+
9+
export const TimelineItem = ({ children }: { children: React.ReactNode }) => {
10+
return (
11+
<li className="mb-10 ms-4 last:mb-0">
12+
<div className="absolute w-3 h-3 bg-border rounded-full mt-1.5 -start-1.5 border border-border"></div>
13+
{children}
14+
</li>
15+
);
16+
};
17+
18+
export const TimelineTime = ({ isoDate }: { isoDate: string }) => {
19+
const formatted = format(parseISO(isoDate), 'dd MMM yyyy');
20+
return <time className="mb-1 text-sm font-normal text-muted-foreground">{formatted}</time>;
21+
};
22+
23+
export const TimelineTitle = ({ children }: { children: React.ReactNode }) => {
24+
return <h3 className="text-lg font-semibold text-gray-900 dark:text-white">{children}</h3>;
25+
};
26+
27+
export const TimelineDescription = ({ children, className }: { children: React.ReactNode; className?: string }) => {
28+
return <div className={cn(className)}>{children}</div>;
29+
};

0 commit comments

Comments
 (0)