Skip to content

Commit 1e6c614

Browse files
committed
2025.03.27 back to work
1 parent e739711 commit 1e6c614

File tree

17 files changed

+891
-27
lines changed

17 files changed

+891
-27
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,5 @@ yarn-error.log*
3939
# typescript
4040
*.tsbuildinfo
4141
next-env.d.ts
42+
43+
dist
Lines changed: 132 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,158 @@
11
import { graphqlRequest } from '@/lib/graphql-request';
2-
import { RankByLoginDocument } from '@/types/generated/graphql';
2+
import { RankByLoginDocument, RankByLoginQuery } from '@/types/generated/graphql';
33
import { redirect } from 'next/navigation';
4-
import { ImageResponse } from 'next/og';
54
import { NextRequest } from 'next/server';
5+
import satori, { SatoriOptions } from 'satori';
6+
import path from 'path';
7+
import { promises as fs } from 'fs';
8+
import { emojiMapping } from '@/utils/emoji-mapping';
9+
import { Follower, PullRequest, Star } from '@/components/icons';
10+
import { figmaVariables } from '@/utils/figma-variables-mapping';
611

12+
type WidgetType = 'stars' | 'contributions' | 'followers';
713
type Props = {
8-
params: Promise<{
9-
login: string;
10-
widgetType: string;
11-
}>;
14+
params: Promise<{ login: string; widgetType: WidgetType }>;
15+
};
16+
17+
async function loadFont(fontWeight: string): Promise<Buffer> {
18+
return fs.readFile(path.join(process.cwd(), 'public', 'fonts', `Inter-${fontWeight}.ttf`));
19+
}
20+
21+
const [regularFontFile, boldFontFile, semiBoldFontFile] = await Promise.all([
22+
loadFont('Regular'),
23+
loadFont('Bold'),
24+
loadFont('SemiBold'),
25+
]);
26+
27+
const options: SatoriOptions = {
28+
width: 200,
29+
height: 52,
30+
embedFont: true,
31+
fonts: [
32+
{ name: 'Inter', data: regularFontFile, weight: 400, style: 'normal' },
33+
{ name: 'Inter', data: semiBoldFontFile, weight: 600, style: 'normal' },
34+
{ name: 'Inter', data: boldFontFile, weight: 700, style: 'normal' },
35+
],
36+
loadAdditionalAsset: async (_, segment: string) => emojiMapping[segment] || '',
37+
};
38+
39+
const getRank = (
40+
data: RankByLoginQuery['rankByLogin'],
41+
widgetType: WidgetType,
42+
): { rank?: number; delta?: number; sentiment?: 'positive' | 'negative' } => {
43+
let rank;
44+
let monthlyRank;
45+
46+
if (!data) {
47+
return { rank: undefined, delta: undefined, sentiment: undefined };
48+
}
49+
50+
switch (widgetType) {
51+
case 'stars':
52+
rank = data.ownedStars;
53+
monthlyRank = data.ownedStarsM;
54+
break;
55+
case 'contributions':
56+
rank = data.contributedStars;
57+
monthlyRank = data.contributedStarsM;
58+
break;
59+
case 'followers':
60+
rank = data.followersCount;
61+
monthlyRank = data.followersCountM;
62+
break;
63+
}
64+
65+
const delta = rank - (monthlyRank ?? rank);
66+
67+
return { rank, delta: Math.abs(delta), sentiment: delta > 0 ? 'positive' : 'negative' };
68+
};
69+
70+
const getWidgetIcon = (widgetType: WidgetType) => {
71+
switch (widgetType) {
72+
case 'stars':
73+
return Star;
74+
case 'contributions':
75+
return PullRequest;
76+
case 'followers':
77+
return Follower;
78+
}
1279
};
1380

1481
export async function GET(req: NextRequest, { params }: Props) {
15-
const { login } = await params;
82+
const theme = 'light';
83+
const { colors } = figmaVariables[theme];
84+
const { login, widgetType } = await params;
1685

1786
const { rankByLogin } = await graphqlRequest(RankByLoginDocument, { login });
1887

19-
console.log(rankByLogin);
20-
2188
if (!rankByLogin) {
2289
return redirect('/404');
2390
}
2491

25-
const { user, ownedStars } = rankByLogin;
92+
const { rank, delta, sentiment } = getRank(rankByLogin, widgetType);
93+
94+
if (!rank || !sentiment) {
95+
return redirect('/404');
96+
}
97+
98+
const Icon = getWidgetIcon(widgetType);
99+
100+
const svg = await satori(
101+
<div
102+
style={{
103+
display: 'flex',
104+
gap: 12,
105+
padding: 8,
106+
color: colors.text.primary,
107+
backgroundColor: colors.surface.primary,
108+
fontFamily: 'Inter',
109+
width: 200,
110+
height: 52,
111+
borderRadius: 8,
112+
}}
113+
>
114+
<Icon width={32} height={32} />
26115

27-
return new ImageResponse(
28-
(
29116
<div
30117
style={{
31-
height: '100%',
32-
width: '100%',
33118
display: 'flex',
119+
flexGrow: 1,
34120
flexDirection: 'column',
35-
alignItems: 'center',
36121
justifyContent: 'center',
37-
backgroundColor: '#fff',
38-
fontSize: 32,
39-
fontWeight: 600,
122+
alignItems: 'center',
40123
}}
41124
>
42-
{/* eslint-disable-next-line @next/next/no-img-element */}
43-
{!!user?.avatarUrl && <img src={user?.avatarUrl} width={100} height={100} alt="user avatar" />}
44-
<div style={{ marginTop: 40 }}>{`${user?.login}: #${ownedStars} with ${user?.ownedStars} stars`}</div>
125+
<div style={{ fontWeight: 400, fontSize: 10, letterSpacing: '0.5px' }}>GITHUB RANK</div>
126+
<div style={{ fontWeight: 600, fontSize: 18 }}>{rank.toLocaleString('en-US')}</div>
45127
</div>
46-
),
47-
{
48-
width: 600,
49-
height: 300,
50-
},
128+
129+
{!!delta && (
130+
<div
131+
style={{
132+
display: 'flex',
133+
flexDirection: 'column',
134+
justifyContent: 'center',
135+
alignItems: 'center',
136+
fontWeight: 400,
137+
fontSize: 8,
138+
color: colors.text[sentiment],
139+
}}
140+
>
141+
{sentiment === 'positive' && '▲'}
142+
<div style={{ fontWeight: 700, fontSize: 12 }}>{delta.toLocaleString('en-US')}</div>
143+
{sentiment === 'negative' && '▼'}
144+
</div>
145+
)}
146+
147+
{/* {!!user?.avatarUrl && <img src={user?.avatarUrl} width={100} height={100} alt="user avatar" />} */}
148+
</div>,
149+
options,
51150
);
151+
152+
return new Response(svg, {
153+
headers: {
154+
'Content-Type': 'image/svg+xml',
155+
'Cache-Control': 'max-age=300, public', // Cache badge for 5 minutes
156+
},
157+
});
52158
}

components/icons/follower.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as React from "react";
2+
import type { SVGProps } from "react";
3+
const SvgFollower = (props: SVGProps<SVGSVGElement>) => (
4+
<svg
5+
xmlns="http://www.w3.org/2000/svg"
6+
fill="none"
7+
viewBox="0 0 24 24"
8+
{...props}
9+
>
10+
<path
11+
stroke="currentColor"
12+
strokeLinecap="round"
13+
strokeLinejoin="round"
14+
strokeWidth={2}
15+
d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"
16+
/>
17+
</svg>
18+
);
19+
export default SvgFollower;

components/icons/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { default as Follower } from "./follower";
2+
export { default as PullRequest } from "./pull-request";
3+
export { default as Star } from "./star";

components/icons/pull-request.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as React from "react";
2+
import type { SVGProps } from "react";
3+
const SvgPullRequest = (props: SVGProps<SVGSVGElement>) => (
4+
<svg
5+
xmlns="http://www.w3.org/2000/svg"
6+
fill="none"
7+
viewBox="0 0 24 24"
8+
{...props}
9+
>
10+
<path
11+
stroke="currentColor"
12+
strokeLinecap="round"
13+
strokeLinejoin="round"
14+
strokeWidth={2}
15+
d="M18 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6M6 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6M13 6h3a2 2 0 0 1 2 2v7M6 9v12"
16+
/>
17+
</svg>
18+
);
19+
export default SvgPullRequest;

components/icons/star.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as React from "react";
2+
import type { SVGProps } from "react";
3+
const SvgStar = (props: SVGProps<SVGSVGElement>) => (
4+
<svg
5+
xmlns="http://www.w3.org/2000/svg"
6+
fill="none"
7+
viewBox="0 0 24 24"
8+
{...props}
9+
>
10+
<path
11+
stroke="currentColor"
12+
strokeLinecap="round"
13+
strokeLinejoin="round"
14+
strokeWidth={2}
15+
d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01z"
16+
/>
17+
</svg>
18+
);
19+
export default SvgStar;

lib/figma/download-icons.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
const FIGMA_TOKEN = process.env.FIGMA_TOKEN!;
5+
const FILE_ID = '9ZmfDu5ZbIUS8rO3YlrIEu';
6+
const ICONS_OUTPUT_DIR = './public/icons';
7+
8+
if (!FIGMA_TOKEN) {
9+
throw new Error('❌ FIGMA_TOKEN is not defined in environment variables');
10+
}
11+
12+
interface FigmaComponent {
13+
node_id: string;
14+
name: string;
15+
}
16+
17+
interface FigmaComponentsResponse {
18+
meta: {
19+
components: FigmaComponent[];
20+
};
21+
}
22+
23+
interface FigmaImagesResponse {
24+
images: Record<string, string>;
25+
}
26+
27+
async function fetchFigmaComponents(): Promise<FigmaComponent[]> {
28+
const res = await fetch(`https://api.figma.com/v1/files/${FILE_ID}/components`, {
29+
headers: {
30+
'X-Figma-Token': FIGMA_TOKEN,
31+
},
32+
});
33+
34+
if (!res.ok) {
35+
throw new Error(`❌ Failed to fetch components: ${res.statusText}`);
36+
}
37+
38+
const data: FigmaComponentsResponse = await res.json();
39+
return data.meta.components;
40+
}
41+
42+
async function fetchSvgUrls(nodeIds: string[]): Promise<Record<string, string>> {
43+
const res = await fetch(`https://api.figma.com/v1/images/${FILE_ID}?ids=${nodeIds.join(',')}&format=svg`, {
44+
headers: {
45+
'X-Figma-Token': FIGMA_TOKEN,
46+
},
47+
});
48+
49+
if (!res.ok) {
50+
throw new Error(`❌ Failed to fetch SVG URLs: ${res.statusText}`);
51+
}
52+
53+
const data: FigmaImagesResponse = await res.json();
54+
return data.images;
55+
}
56+
57+
async function downloadSvg(name: string, url: string): Promise<void> {
58+
const res = await fetch(url);
59+
if (!res.ok) {
60+
throw new Error(`❌ Failed to download ${name}: ${res.statusText}`);
61+
}
62+
63+
const svg = await res.text();
64+
const outputPath = path.join(ICONS_OUTPUT_DIR, `${name}.svg`);
65+
fs.writeFileSync(outputPath, svg);
66+
console.log(`✅ Downloaded ${name}.svg`);
67+
}
68+
69+
async function main(): Promise<void> {
70+
if (!fs.existsSync(ICONS_OUTPUT_DIR)) {
71+
fs.mkdirSync(ICONS_OUTPUT_DIR, { recursive: true });
72+
}
73+
74+
console.log('📥 Fetching Figma components...');
75+
const components = await fetchFigmaComponents();
76+
const nodeIds = components.map((c) => c.node_id);
77+
const nameMap = Object.fromEntries(components.map((c) => [c.node_id, c.name]));
78+
79+
console.log('🔗 Fetching SVG URLs...');
80+
const svgUrls = await fetchSvgUrls(nodeIds);
81+
82+
console.log('💾 Downloading icons...');
83+
for (const [nodeId, url] of Object.entries(svgUrls)) {
84+
const name = nameMap[nodeId] ?? `icon-${nodeId}`;
85+
await downloadSvg(name, url);
86+
}
87+
88+
console.log('✅ All icons downloaded.');
89+
}
90+
91+
main().catch((err) => {
92+
console.error(err);
93+
process.exit(1);
94+
});

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
"start": "next start",
99
"lint": "next lint",
1010
"codegen": "dotenv -e .env.local graphql-codegen",
11-
"standalone": "dotenv -e .env.local node .next/standalone/server.js"
11+
"standalone": "dotenv -e .env.local node .next/standalone/server.js",
12+
"icons": "pnpm dlx @svgr/cli --out-dir components/icons --filename-case kebab --typescript --no-dimensions --replace-attr-values \"#000=currentColor\" -- public/icons",
13+
"figma:icons": "tsc lib/figma/download-icons.ts --outDir dist && dotenv -e .env.local node dist/download-icons.js && pnpm run icons"
1214
},
1315
"dependencies": {
1416
"@auth/mongodb-adapter": "^3.7.4",
@@ -21,13 +23,15 @@
2123
"next-auth": "5.0.0-beta.25",
2224
"react": "^19.0.0",
2325
"react-dom": "^19.0.0",
26+
"satori": "^0.12.1",
2427
"tailwind-merge": "^3.0.1",
2528
"tailwindcss-animate": "^1.0.7"
2629
},
2730
"devDependencies": {
2831
"@eslint/eslintrc": "^3",
2932
"@graphql-codegen/cli": "^5.0.5",
3033
"@graphql-codegen/typed-document-node": "^5.0.13",
34+
"@svgr/cli": "^8.1.0",
3135
"@tailwindcss/postcss": "^4.0.0",
3236
"@types/node": "^20",
3337
"@types/react": "^19",

0 commit comments

Comments
 (0)