|
1 | 1 | import { graphqlRequest } from '@/lib/graphql-request'; |
2 | | -import { RankByLoginDocument } from '@/types/generated/graphql'; |
| 2 | +import { RankByLoginDocument, RankByLoginQuery } from '@/types/generated/graphql'; |
3 | 3 | import { redirect } from 'next/navigation'; |
4 | | -import { ImageResponse } from 'next/og'; |
5 | 4 | 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'; |
6 | 11 |
|
| 12 | +type WidgetType = 'stars' | 'contributions' | 'followers'; |
7 | 13 | 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 | + } |
12 | 79 | }; |
13 | 80 |
|
14 | 81 | 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; |
16 | 85 |
|
17 | 86 | const { rankByLogin } = await graphqlRequest(RankByLoginDocument, { login }); |
18 | 87 |
|
19 | | - console.log(rankByLogin); |
20 | | - |
21 | 88 | if (!rankByLogin) { |
22 | 89 | return redirect('/404'); |
23 | 90 | } |
24 | 91 |
|
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} /> |
26 | 115 |
|
27 | | - return new ImageResponse( |
28 | | - ( |
29 | 116 | <div |
30 | 117 | style={{ |
31 | | - height: '100%', |
32 | | - width: '100%', |
33 | 118 | display: 'flex', |
| 119 | + flexGrow: 1, |
34 | 120 | flexDirection: 'column', |
35 | | - alignItems: 'center', |
36 | 121 | justifyContent: 'center', |
37 | | - backgroundColor: '#fff', |
38 | | - fontSize: 32, |
39 | | - fontWeight: 600, |
| 122 | + alignItems: 'center', |
40 | 123 | }} |
41 | 124 | > |
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> |
45 | 127 | </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, |
51 | 150 | ); |
| 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 | + }); |
52 | 158 | } |
0 commit comments