Skip to content

Commit 1263080

Browse files
committed
badge generator
1 parent ca38f0a commit 1263080

24 files changed

+649
-160
lines changed

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

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

44
import { BadgeTemplateType } from '@/badge/badge.types';
5+
import { BadgeZodSchema } from '@/badge/badge.zod';
56
import { renderMediumBadge } from '@/badge/templates/medium/medium.render';
67
import { renderSmallBadge } from '@/badge/templates/small/small.render';
78
import { posthog } from '@/lib/posthog/posthog-node-client';
8-
import { RankingType } from '@/types/ranking.types';
9-
import { ThemeType } from '@/types/theme.types';
109

1110
type Props = { params: Promise<{ login: string }> };
1211

1312
const getRendererByTemplate = (template: BadgeTemplateType) => {
1413
switch (template) {
15-
case 'small':
14+
case BadgeTemplateType.Small:
1615
return renderSmallBadge;
1716
default:
1817
return renderMediumBadge;
@@ -23,9 +22,20 @@ export async function GET(req: NextRequest, { params }: Props) {
2322
const { login } = await params;
2423

2524
const searchParams = req.nextUrl.searchParams;
26-
const rankingType = (searchParams.get('rankingType') ?? 'star') as RankingType;
27-
const template = (searchParams.get('template') ?? 'medium') as BadgeTemplateType;
28-
const theme = (searchParams.get('theme') ?? 'light') as ThemeType;
25+
const badgeParams = Object.fromEntries(searchParams.entries());
26+
const validationResult = BadgeZodSchema.safeParse(badgeParams);
27+
28+
if (!validationResult.success) {
29+
return new Response('Invalid query params', {
30+
status: 400,
31+
headers: {
32+
'Content-Type': 'text/plain',
33+
'Cache-Control': 'max-age=300, public',
34+
},
35+
});
36+
}
37+
38+
const { rankingType, template, theme } = validationResult.data;
2939

3040
posthog.capture({
3141
distinctId: login,

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

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

app/badge/[[...login]]/badge-generator.tsx

Lines changed: 0 additions & 21 deletions
This file was deleted.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
'use client';
2+
3+
import { zodResolver } from '@hookform/resolvers/zod';
4+
import { useQueryStates } from 'nuqs';
5+
import { useEffect } from 'react';
6+
import { useForm } from 'react-hook-form';
7+
import { z } from 'zod';
8+
9+
import { BadgeNuqsSchema } from '@/badge/badge.nuqs';
10+
import { BadgeTemplateType } from '@/badge/badge.types';
11+
import { BadgeZodSchema } from '@/badge/badge.zod';
12+
import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form';
13+
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
14+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
15+
import { RankingType } from '@/types/ranking.types';
16+
import { ThemeType } from '@/types/theme.types';
17+
18+
import { StepTitle } from './step-title';
19+
20+
export function BadgeForm() {
21+
const [defaultValues, saveValues] = useQueryStates(BadgeNuqsSchema);
22+
23+
const form = useForm<Partial<z.infer<typeof BadgeZodSchema>>>({
24+
resolver: zodResolver(BadgeZodSchema),
25+
defaultValues,
26+
});
27+
28+
useEffect(() => {
29+
const subscription = form.watch((value) => saveValues(value));
30+
return () => subscription.unsubscribe();
31+
}, [form, saveValues]);
32+
33+
return (
34+
<div className="flex flex-col gap-4">
35+
<StepTitle>Step 2. Customize Badge Appearance</StepTitle>
36+
37+
<Form {...form}>
38+
<FormField
39+
control={form.control}
40+
name="rankingType"
41+
render={({ field }) => (
42+
<FormItem>
43+
<FormLabel>Ranking Type</FormLabel>
44+
<Select onValueChange={field.onChange} defaultValue={field.value}>
45+
<FormControl>
46+
<SelectTrigger className="min-w-3xs">
47+
<SelectValue placeholder="Select a ranking type" />
48+
</SelectTrigger>
49+
</FormControl>
50+
<SelectContent>
51+
<SelectItem value={RankingType.Star}>Stars</SelectItem>
52+
<SelectItem value={RankingType.Contribution}>Contributions</SelectItem>
53+
<SelectItem value={RankingType.Follower}>Followers</SelectItem>
54+
</SelectContent>
55+
</Select>
56+
</FormItem>
57+
)}
58+
/>
59+
60+
<FormField
61+
control={form.control}
62+
name="template"
63+
render={({ field }) => (
64+
<FormItem>
65+
<FormLabel>Template</FormLabel>
66+
<Select onValueChange={field.onChange} defaultValue={field.value}>
67+
<FormControl>
68+
<SelectTrigger className="min-w-3xs">
69+
<SelectValue placeholder="Select a template" />
70+
</SelectTrigger>
71+
</FormControl>
72+
<SelectContent>
73+
<SelectItem value={BadgeTemplateType.Medium}>Medium</SelectItem>
74+
<SelectItem value={BadgeTemplateType.Small}>Small</SelectItem>
75+
</SelectContent>
76+
</Select>
77+
</FormItem>
78+
)}
79+
/>
80+
81+
<FormField
82+
control={form.control}
83+
name="theme"
84+
render={({ field }) => (
85+
<FormItem>
86+
<FormLabel>Theme</FormLabel>
87+
<FormControl>
88+
<RadioGroup onValueChange={field.onChange} defaultValue={field.value} className="flex gap-6">
89+
<FormItem className="flex items-center">
90+
<FormControl>
91+
<RadioGroupItem value={ThemeType.Light} />
92+
</FormControl>
93+
<FormLabel className="font-normal">Light</FormLabel>
94+
</FormItem>
95+
<FormItem className="flex items-center">
96+
<FormControl>
97+
<RadioGroupItem value={ThemeType.Dark} />
98+
</FormControl>
99+
<FormLabel className="font-normal">Dark</FormLabel>
100+
</FormItem>
101+
</RadioGroup>
102+
</FormControl>
103+
</FormItem>
104+
)}
105+
/>
106+
</Form>
107+
</div>
108+
);
109+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
'use client';
2+
3+
import { FC } from 'react';
4+
5+
import { Textarea } from '@/components/ui/textarea';
6+
7+
import { LoginFormProps } from './login-form.types';
8+
import { StepTitle } from './step-title';
9+
import { useBadgeUrl } from '../hooks/useBadgeUrl';
10+
11+
export const IntegrationCode: FC<LoginFormProps> = ({ githubLogin, githubId }) => {
12+
const url = useBadgeUrl(githubLogin, githubId);
13+
14+
return (
15+
<div className="flex flex-col gap-4">
16+
<StepTitle>Step 3. Copy the Code to Your GitHub README</StepTitle>
17+
<Textarea readOnly value={githubId ? `<img src="${url}" />` : ''}></Textarea>
18+
</div>
19+
);
20+
};
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use client';
2+
3+
import { zodResolver } from '@hookform/resolvers/zod';
4+
import { Search } from 'lucide-react';
5+
import { useRouter, useSearchParams } from 'next/navigation';
6+
import { FC } from 'react';
7+
import { useForm } from 'react-hook-form';
8+
import { z } from 'zod';
9+
10+
import { Button } from '@/components/ui/button';
11+
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
12+
import { Input } from '@/components/ui/input';
13+
14+
import { LoginFormProps } from './login-form.types';
15+
import { StepTitle } from './step-title';
16+
17+
const FormSchema = z.object({
18+
login: z.string().min(1, { message: 'GitHub login is required' }),
19+
});
20+
21+
export const LoginForm: FC<LoginFormProps> = ({ githubLogin = '', githubId }) => {
22+
const router = useRouter();
23+
const searchParams = useSearchParams();
24+
25+
const form = useForm<z.infer<typeof FormSchema>>({
26+
resolver: zodResolver(FormSchema),
27+
defaultValues: { login: githubLogin },
28+
errors: {
29+
login: {
30+
type: 'custom',
31+
message:
32+
githubLogin && !githubId ? 'Oops! We looked everywhere but found no trace of that GitHuber' : undefined,
33+
},
34+
},
35+
});
36+
37+
function onSubmit(data: z.infer<typeof FormSchema>) {
38+
router.push(`/badge/${data.login}?${searchParams.toString()}`);
39+
}
40+
41+
return (
42+
<div className="flex flex-col gap-4">
43+
<StepTitle>Step 1. Choose a GitHub Profile</StepTitle>
44+
<Form {...form}>
45+
<form onSubmit={form.handleSubmit(onSubmit)}>
46+
<FormField
47+
control={form.control}
48+
name="login"
49+
render={({ field }) => (
50+
<FormItem>
51+
<div className="flex gap-4">
52+
<Input {...field} placeholder="GitHub login" className="flex-grow min-w-3xs" />
53+
54+
<Button type="submit">
55+
<Search className="size-4" />
56+
Search
57+
</Button>
58+
</div>
59+
<FormMessage className="text-negative" />
60+
</FormItem>
61+
)}
62+
/>
63+
</form>
64+
</Form>
65+
</div>
66+
);
67+
};
File renamed without changes.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use client';
2+
3+
import { FC } from 'react';
4+
5+
import { LoginFormProps } from './login-form.types';
6+
import { useBadgeUrl } from '../hooks/useBadgeUrl';
7+
8+
export const Preview: FC<LoginFormProps> = ({ githubLogin, githubId }) => {
9+
const url = useBadgeUrl(githubLogin, githubId);
10+
11+
return (
12+
<div className="flex flex-grow items-start justify-center">
13+
<div className="flex flex-col max-w-lg min-w-xs border-border border-1 rounded-lg p-6 gap-3">
14+
<p className="text-xs">devwizard/README.md</p>
15+
<p className="text-lg font-semibold">Hi, I&apos;m devwizard 👋</p>
16+
<p>
17+
I&apos;m a full-stack developer with a passion for building open-source tools and learning new tech every day.
18+
</p>
19+
{url && (
20+
<div className="py-2">
21+
<img src={url} alt="badge" />
22+
</div>
23+
)}
24+
<p className="text-lg font-semibold">Tech Stack 🧰</p>
25+
<ul className="list-disc pl-4">
26+
<li>JavaScript / TypeScript</li>
27+
<li>Node.js / NestJS</li>
28+
<li>React / Next.js</li>
29+
<li>MongoDB / PostgreSQL</li>
30+
<li>Docker / GitHub Actions</li>
31+
</ul>
32+
</div>
33+
</div>
34+
);
35+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { PropsWithChildren } from 'react';
2+
3+
export const StepTitle = ({ children }: PropsWithChildren) => {
4+
return <div className="font-medium">{children}</div>;
5+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useQueryStates } from 'nuqs';
2+
3+
import { BadgeNuqsSchema } from '@/badge/badge.nuqs';
4+
5+
export const useBadgeUrl = (githubLogin: string | undefined, githubId: string | undefined) => {
6+
const [badgeParams] = useQueryStates(BadgeNuqsSchema);
7+
8+
if (!githubId) {
9+
return '';
10+
}
11+
12+
const queryParams = new URLSearchParams(badgeParams).toString();
13+
const origin = typeof window !== 'undefined' ? window.location.origin : '';
14+
return `${origin}/api/badge/${githubLogin}` + (queryParams ? '?' : '') + queryParams;
15+
};

0 commit comments

Comments
 (0)