Skip to content

Commit 7d36266

Browse files
authored
Language ranks (#5)
* Improve profile page SEO * Redesign overview cards * Update language card on profile overview * Refactor profile page (prep for new languages tab) * Fix lint issues and prepare for release * Update Next.js route typings * Migrate to Biome * Fix country order type guard
1 parent bb42c71 commit 7d36266

File tree

124 files changed

+3861
-1303
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

124 files changed

+3861
-1303
lines changed

app/api/ai/route.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import type { Session } from 'next-auth';
3+
4+
import { auth } from '@/auth';
5+
import { signedFetch } from '@/lib/signed-fetch';
6+
7+
export type AuthRequest = NextRequest & { auth: Session | null };
8+
9+
export const POST = auth(async function POST(req: AuthRequest, { params }) {
10+
const { login } = await params;
11+
12+
const response = await signedFetch('/user/generate-description', {
13+
method: 'POST',
14+
headers: { 'Content-Type': 'application/json' },
15+
body: JSON.stringify({ login }),
16+
});
17+
18+
const responseData = await response.json();
19+
20+
if (!response.ok) {
21+
return NextResponse.json(
22+
{ message: 'Failed to generate user description', error: responseData },
23+
{ status: response.status },
24+
);
25+
}
26+
27+
return NextResponse.json(responseData);
28+
});

app/api/avatar/[login]/route.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { NextRequest } from 'next/server';
2+
3+
import { graphqlDirect } from '@/lib/graphql/graphql-direct';
4+
import { AvatarByLoginDocument } from '@/types/generated/graphql';
5+
6+
type Props = { params: Promise<{ login: string }> };
7+
8+
export async function GET(req: NextRequest, { params }: Props) {
9+
const { login } = await params;
10+
11+
const { user } = await graphqlDirect(AvatarByLoginDocument, { login });
12+
13+
if (!user?.avatarUrl) {
14+
return new Response('User not found', { status: 404 });
15+
}
16+
17+
const src = user.avatarUrl;
18+
19+
// Forward validators for 304 support
20+
const etag = req.headers.get('if-none-match') ?? undefined;
21+
const ims = req.headers.get('if-modified-since') ?? undefined;
22+
23+
const upstream = await fetch(src, {
24+
headers: {
25+
...(etag ? { 'if-none-match': etag } : {}),
26+
...(ims ? { 'if-modified-since': ims } : {}),
27+
// Optional: set Accept for smaller formats if GH honors it (not guaranteed)
28+
Accept: req.headers.get('accept') ?? 'image/*',
29+
// Never forward arbitrary user-provided headers
30+
},
31+
// Let your platform/CDN cache it:
32+
next: { revalidate: 60 * 60 * 24 }, // 1 day (app-level hint)
33+
});
34+
35+
// Pass through 304 to leverage browser/CDN cache
36+
if (upstream.status === 304) {
37+
return new Response(null, {
38+
status: 304,
39+
headers: {
40+
'Cache-Control': 'public, max-age=0, s-maxage=604800, stale-while-revalidate=86400',
41+
},
42+
});
43+
}
44+
45+
if (!upstream.ok) {
46+
// if GitHub itself returns 404 → bubble it up
47+
return new Response('Avatar not found', { status: 404 });
48+
}
49+
50+
// Stream the body; copy key headers safely
51+
const resHeaders = new Headers();
52+
const ct = upstream.headers.get('content-type');
53+
if (ct) resHeaders.set('Content-Type', ct);
54+
55+
const lm = upstream.headers.get('last-modified');
56+
if (lm) resHeaders.set('Last-Modified', lm);
57+
58+
const et = upstream.headers.get('etag');
59+
if (et) resHeaders.set('ETag', et);
60+
61+
// Your cache policy
62+
resHeaders.set('Cache-Control', 'public, max-age=0, s-maxage=604800, stale-while-revalidate=86400');
63+
// Optional CSP tightening:
64+
// resHeaders.set("Cross-Origin-Resource-Policy", "same-site");
65+
66+
return new Response(upstream.body, {
67+
status: upstream.status,
68+
headers: resHeaders,
69+
});
70+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { redirect } from 'next/navigation';
1+
import { notFound } from 'next/navigation';
22
import { NextRequest } from 'next/server';
33

44
import { BadgeTemplateType } from '@/badge/badge.types';
@@ -46,7 +46,7 @@ export async function GET(req: NextRequest, { params }: Props) {
4646
const svg = await getRendererByTemplate(template)({ theme, login, rankingType });
4747

4848
if (!svg) {
49-
return redirect('/404');
49+
return notFound();
5050
}
5151

5252
return new Response(svg, {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { redirect } from 'next/navigation';
1+
import { notFound } from 'next/navigation';
22
import { NextRequest } from 'next/server';
33

44
import { BadgeV2ZodSchema } from '@/badge/badge.zod';
@@ -36,7 +36,7 @@ export async function GET(req: NextRequest, { params }: Props) {
3636
const svg = await renderInlineBadge({ login, params: validationResult.data });
3737

3838
if (!svg) {
39-
return redirect('/404');
39+
return notFound();
4040
}
4141

4242
return new Response(svg, {

app/app.consts.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,5 @@ export const RANK_DESCRIPTIONS = {
3434
notRankedMessage: `A profile needs at least ${MIN_VALUE} followers to be ranked.`,
3535
},
3636
};
37+
38+
export const DEFAULT_LANGUAGE_COLOR = '#64748B';

app/badge/builder/[[...login]]/components/login-form.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { zodResolver } from '@hookform/resolvers/zod';
44
import { Edit, Search } from 'lucide-react';
5+
import type { Route } from 'next';
56
import Link from 'next/link';
67
import { useRouter, useSearchParams } from 'next/navigation';
78
import { FC } from 'react';
@@ -29,8 +30,13 @@ export const LoginForm: FC<LoginFormProps> = ({ githubLogin = '', githubId }) =>
2930
});
3031

3132
function onSubmit(data: z.infer<typeof FormSchema>) {
32-
const url = `/badge/builder${githubId ? '' : `/${data.login}`}?${searchParams.toString()}`;
33-
router.push(url);
33+
const qs = searchParams.toString();
34+
35+
if (githubId) {
36+
router.push(`/badge/builder?${qs}` as Route);
37+
} else {
38+
router.push(`/badge/builder/${data.login}?${qs}` as Route);
39+
}
3440
}
3541

3642
return (

app/badge/builder/[[...login]]/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const metadata: Metadata = {
99
'Generate custom GitHub badges with GitRanks. Show your global and country rankings, percentile, monthly changes, and progress toward the next tier.',
1010
};
1111

12-
export default function BadgeLayout({ children }: Readonly<{ children: React.ReactNode }>) {
12+
export default function BadgeLayout({ children }: LayoutProps<'/badge/builder/[[...login]]'>) {
1313
return (
1414
<>
1515
<Header />

app/badge/builder/[[...login]]/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { IntegrationCode } from './components/integration-code';
1111
import { LoginForm } from './components/login-form';
1212
import { Preview } from './components/preview';
1313

14-
export default async function Badge({ params }: { params: Promise<{ login?: string[] }> }) {
14+
export default async function Badge({ params }: PageProps<'/badge/builder/[[...login]]'>) {
1515
cacheLife('hours');
1616

1717
const { login } = await params;

app/badge/gallery/components/badge-example.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Route } from 'next';
12
import Link from 'next/link';
23
import { memo, useMemo } from 'react';
34
import { z } from 'zod';
@@ -49,7 +50,7 @@ function BadgeExample(props: Readonly<BadgeExampleProps>) {
4950
}, [parsed]);
5051

5152
return (
52-
<Link href={builderUrl} aria-label={parsed.label ?? 'Open badge builder'}>
53+
<Link href={builderUrl as Route} aria-label={parsed.label ?? 'Open badge builder'}>
5354
<img src={src} alt={parsed.label ?? 'Badge example'} height={INLINE_BADGE_HEIGHT} decoding="async" />
5455
</Link>
5556
);

app/badge/gallery/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const metadata: Metadata = {
1212
'Browse the complete collection of GitRanks badges. See design examples, colors, and styles you can use to showcase your GitHub achievements.',
1313
};
1414

15-
export default function BadgeGalleryLayout({ children }: Readonly<{ children: React.ReactNode }>) {
15+
export default function BadgeGalleryLayout({ children }: LayoutProps<'/badge/gallery'>) {
1616
return (
1717
<>
1818
<Header />

0 commit comments

Comments
 (0)