Skip to content

Commit b47a2c1

Browse files
committed
2025.03.28 medium badge
1 parent 1e6c614 commit b47a2c1

23 files changed

+455
-159
lines changed

app/api/badge/[login]/route.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { redirect } from 'next/navigation';
2+
import { NextRequest } from 'next/server';
3+
import { BadgeTemplateType, BadgeType } from '@/badge/badge.types';
4+
import { renderSmallBadge } from '@/badge/templates/small/small.render';
5+
import { renderMediumBadge } from '@/badge/templates/medium/medium.render';
6+
7+
type Props = { params: Promise<{ login: string }> };
8+
9+
const getRendererByTemplate = (template: BadgeTemplateType) => {
10+
switch (template) {
11+
case 'small':
12+
return renderSmallBadge;
13+
default:
14+
return renderMediumBadge;
15+
}
16+
};
17+
18+
export async function GET(req: NextRequest, { params }: Props) {
19+
const theme = 'light';
20+
const { login } = await params;
21+
22+
const searchParams = req.nextUrl.searchParams;
23+
const type = searchParams.get('type') as BadgeType;
24+
const template = searchParams.get('template') as BadgeTemplateType;
25+
26+
const svg = await getRendererByTemplate(template)({ theme, login, type });
27+
28+
if (!svg) {
29+
return redirect('/404');
30+
}
31+
32+
return new Response(svg, {
33+
headers: {
34+
'Content-Type': 'image/svg+xml',
35+
'Cache-Control': 'max-age=300, public',
36+
},
37+
});
38+
}

app/api/widget/[login]/[widgetType]/route.tsx

Lines changed: 0 additions & 158 deletions
This file was deleted.

app/by/[type]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export default async function GlobalRanking({
9090
</td>
9191
<td>{user?.login}</td>
9292
<td>{user?.location}</td>
93-
<td>{user?.[rankPropName]}</td>
93+
<td>{user?.[rankPropName]?.toLocaleString('en-US')}</td>
9494
</tr>
9595
);
9696
})}

badge/badge.consts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const GITHUB_TOTAL_USERS = 100_000_000;

badge/badge.types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export type ThemeType = 'light' | 'dark';
2+
3+
export type BadgeType = 'stars' | 'contributions' | 'followers';
4+
5+
export type BadgeTemplateType = 'small' | 'medium';
6+
7+
export type DeltaSentimentType = 'positive' | 'negative';
8+
9+
export type BadgeServiceProps = {
10+
theme: ThemeType;
11+
login: string;
12+
type: BadgeType;
13+
};

app/api/widget/[login]/[widgetType]/rank-by-login.query.gql renamed to badge/queries/rank-by-login.query.gql

File renamed without changes.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const MEDIUM_BADGE_WIDTH = 320;
2+
export const MEDIUM_BADGE_HEIGHT = 160;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import satori from 'satori';
2+
3+
import { graphqlRequest } from '@/lib/graphql-request';
4+
import { RankByLoginDocument } from '@/types/generated/graphql';
5+
import { BadgeMedium } from './medium';
6+
import { getSatoriConfig } from '@/badge/utils/get-satori-config';
7+
import { BadgeServiceProps } from '@/badge/badge.types';
8+
import { MEDIUM_BADGE_HEIGHT, MEDIUM_BADGE_WIDTH } from './medium.consts';
9+
10+
export async function renderMediumBadge({ theme, login, type }: BadgeServiceProps) {
11+
const { rankByLogin } = await graphqlRequest(RankByLoginDocument, { login });
12+
13+
if (!rankByLogin) {
14+
return;
15+
}
16+
17+
return satori(
18+
<BadgeMedium theme={theme} type={type} data={rankByLogin} />,
19+
await getSatoriConfig({
20+
fontOptions: [
21+
{ style: 'normal', weight: 400 },
22+
{ style: 'normal', weight: 600 },
23+
{ style: 'normal', weight: 700 },
24+
],
25+
width: MEDIUM_BADGE_WIDTH,
26+
height: MEDIUM_BADGE_HEIGHT,
27+
}),
28+
);
29+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { MEDIUM_BADGE_HEIGHT, MEDIUM_BADGE_WIDTH } from './medium.consts';
2+
3+
export const containerStyles = {
4+
display: 'flex',
5+
flexDirection: 'column',
6+
justifyContent: 'center',
7+
alignItems: 'center',
8+
gap: 16,
9+
padding: 16,
10+
fontFamily: 'Inter',
11+
width: MEDIUM_BADGE_WIDTH,
12+
height: MEDIUM_BADGE_HEIGHT,
13+
borderRadius: 8,
14+
} as const;
15+
16+
export const rankStyles = {
17+
display: 'flex',
18+
flexGrow: 1,
19+
justifyContent: 'center',
20+
alignItems: 'center',
21+
fontSize: 24,
22+
fontWeight: 700,
23+
} as const;
24+
25+
export const metaItemStyles = {
26+
display: 'flex',
27+
flexDirection: 'column',
28+
flexGrow: 1,
29+
alignItems: 'center',
30+
justifyContent: 'center',
31+
} as const;
32+
33+
export const subtitleStyles = { display: 'flex', fontSize: 10, fontWeight: 400 };
34+
35+
export const rankDeltaStyles = {
36+
display: 'flex',
37+
justifyContent: 'center',
38+
alignItems: 'center',
39+
fontSize: 14,
40+
fontWeight: 600,
41+
} as const;

badge/templates/medium/medium.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { FC } from 'react';
2+
import { BadgeIcon } from '@/components/badge-icon/badge-icon';
3+
import { figmaVariables } from '@/badge/utils/figma-variables-mapping';
4+
import { BadgeMediumProps } from './medium.types';
5+
import { getRankByType } from '@/badge/utils/get-rank-by-type';
6+
import { getTitleByType } from '@/badge/utils/get-title-by-type';
7+
import { BadgeType } from '@/badge/badge.types';
8+
import { containerStyles, metaItemStyles, rankDeltaStyles, rankStyles, subtitleStyles } from './medium.styles';
9+
import { getUsersBehindMe } from '@/badge/utils/users-behind-me';
10+
11+
const getSubtitleByType = (type: BadgeType) => {
12+
switch (type) {
13+
case 'stars':
14+
return 'based on stars from repos owned by the user';
15+
case 'contributions':
16+
return 'based on stars from repos the user contributed to';
17+
case 'followers':
18+
return 'based on the number of followers the user has';
19+
}
20+
};
21+
22+
export const BadgeMedium: FC<BadgeMediumProps> = ({ theme, type, data }) => {
23+
const { colors } = figmaVariables[theme];
24+
25+
const { rank, delta = 0, value, sentiment } = getRankByType(data, type);
26+
const title = getTitleByType(type);
27+
const subtitle = getSubtitleByType(type);
28+
29+
if (!rank || !sentiment) {
30+
return null;
31+
}
32+
33+
const entityName = type === 'followers' ? 'followers' : 'stars';
34+
35+
return (
36+
<div
37+
style={{
38+
...containerStyles,
39+
color: colors.text.primary,
40+
backgroundColor: colors.surface.primary,
41+
}}
42+
>
43+
<div style={{ display: 'flex', gap: 12 }}>
44+
<BadgeIcon type={type} size={32} />
45+
<div style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, justifyContent: 'center' }}>
46+
<div style={{ fontSize: 16, fontWeight: 600 }}>{title}</div>
47+
<div style={subtitleStyles}>{subtitle}</div>
48+
</div>
49+
</div>
50+
51+
<div style={rankStyles}>{rank.toLocaleString('en-US')}</div>
52+
53+
<div style={{ display: 'flex', width: '100%' }}>
54+
<div style={{ ...metaItemStyles, borderRight: `1px solid ${colors.text.primary}` }}>
55+
<div style={subtitleStyles}>better than</div>
56+
<div style={{ fontSize: 14, fontWeight: 600 }}>{getUsersBehindMe(rank)}</div>
57+
</div>
58+
<div style={{ ...metaItemStyles, borderRight: `1px solid ${colors.text.primary}` }}>
59+
<div style={subtitleStyles}>this month</div>
60+
<div style={{ ...rankDeltaStyles, color: delta ? colors.text[sentiment] : colors.text.primary }}>
61+
{delta > 0 ? '+' : ''}
62+
{delta.toLocaleString('en-US')}
63+
</div>
64+
</div>
65+
<div style={metaItemStyles}>
66+
<div style={subtitleStyles}>total {entityName}</div>
67+
<div style={{ display: 'flex', fontSize: 14, fontWeight: 600 }}>
68+
{(!value ? 0 : value).toLocaleString('en-US')}
69+
</div>
70+
</div>
71+
</div>
72+
</div>
73+
);
74+
};

0 commit comments

Comments
 (0)