Skip to content

Commit 20d4e09

Browse files
feat(web): update GitHub stats and stargazers fetching logic (#2113)
* feat(web): update GitHub stats and stargazers fetching logic * refactor(routes): restructure press kit and route configurations * feat(stargazers): improve GitHub stargazers fetching and grid display * feat(api): improve GitHub API request headers management
1 parent ea7518a commit 20d4e09

File tree

4 files changed

+206
-70
lines changed

4 files changed

+206
-70
lines changed

apps/api/src/routes.ts

Lines changed: 135 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,19 @@ export const API_TAGS = {
2020
INTERNAL: "internal",
2121
APP: "app",
2222
WEBHOOK: "webhook",
23+
PUBLIC: "public",
2324
} as const;
2425

26+
const GITHUB_ORG_REPO = "fastrepl/hyprnote";
27+
28+
interface StargazerCache {
29+
data: { username: string; avatar: string }[];
30+
timestamp: number;
31+
}
32+
33+
let stargazerCache: StargazerCache | null = null;
34+
const CACHE_TTL_MS = 1000 * 60 * 60;
35+
2536
const HealthResponseSchema = z.object({
2637
status: z.string(),
2738
});
@@ -31,17 +42,15 @@ const ChatCompletionMessageSchema = z.object({
3142
content: z.string(),
3243
});
3344

34-
const ChatCompletionRequestSchema = z
35-
.object({
36-
model: z.string().optional(),
37-
messages: z.array(ChatCompletionMessageSchema),
38-
tools: z.array(z.unknown()).optional(),
39-
tool_choice: z.union([z.string(), z.object({})]).optional(),
40-
stream: z.boolean().optional(),
41-
temperature: z.number().optional(),
42-
max_tokens: z.number().optional(),
43-
})
44-
.passthrough();
45+
const ChatCompletionRequestSchema = z.looseObject({
46+
model: z.string().optional(),
47+
messages: z.array(ChatCompletionMessageSchema),
48+
tools: z.array(z.unknown()).optional(),
49+
tool_choice: z.union([z.string(), z.object({})]).optional(),
50+
stream: z.boolean().optional(),
51+
temperature: z.number().optional(),
52+
max_tokens: z.number().optional(),
53+
});
4554

4655
const WebhookSuccessSchema = z.object({
4756
ok: z.boolean(),
@@ -337,3 +346,118 @@ routes.get(
337346
return listenSocketHandler(c, next);
338347
},
339348
);
349+
350+
const StargazersResponseSchema = z.object({
351+
stargazers: z.array(
352+
z.object({
353+
username: z.string(),
354+
avatar: z.string(),
355+
}),
356+
),
357+
});
358+
359+
routes.get(
360+
"/stargazers",
361+
describeRoute({
362+
tags: [API_TAGS.PUBLIC],
363+
summary: "Get GitHub stargazers",
364+
description:
365+
"Returns the most recent GitHub stargazers for the Hyprnote repository. Results are cached for 1 hour.",
366+
responses: {
367+
200: {
368+
description: "List of stargazers",
369+
content: {
370+
"application/json": {
371+
schema: resolver(StargazersResponseSchema),
372+
},
373+
},
374+
},
375+
500: {
376+
description: "Failed to fetch stargazers from GitHub",
377+
content: {
378+
"application/json": {
379+
schema: resolver(z.object({ error: z.string() })),
380+
},
381+
},
382+
},
383+
},
384+
}),
385+
async (c) => {
386+
const now = Date.now();
387+
388+
if (stargazerCache && now - stargazerCache.timestamp < CACHE_TTL_MS) {
389+
return c.json({ stargazers: stargazerCache.data }, 200);
390+
}
391+
392+
const githubToken = process.env.GITHUB_TOKEN;
393+
const githubHeaders: Record<string, string> = {
394+
Accept: "application/vnd.github.v3+json",
395+
"User-Agent": "Hyprnote-API",
396+
};
397+
if (githubToken) {
398+
githubHeaders["Authorization"] = `token ${githubToken}`;
399+
}
400+
401+
try {
402+
const repoResponse = await fetch(
403+
`https://api.github.com/repos/${GITHUB_ORG_REPO}`,
404+
{ headers: githubHeaders },
405+
);
406+
407+
if (!repoResponse.ok) {
408+
throw new Error(`Failed to fetch repo info: ${repoResponse.status}`);
409+
}
410+
411+
const repoData = await repoResponse.json();
412+
const totalStars = repoData.stargazers_count ?? 0;
413+
414+
if (totalStars === 0) {
415+
return c.json({ stargazers: [] }, 200);
416+
}
417+
418+
const count = 512;
419+
const perPage = 100;
420+
const numPages = Math.ceil(Math.min(count, totalStars) / perPage);
421+
const lastPage = Math.ceil(totalStars / perPage);
422+
const startPage = Math.max(1, lastPage - numPages + 1);
423+
424+
const fetchPromises = [];
425+
for (let page = startPage; page <= lastPage; page++) {
426+
fetchPromises.push(
427+
fetch(
428+
`https://api.github.com/repos/${GITHUB_ORG_REPO}/stargazers?per_page=${perPage}&page=${page}`,
429+
{ headers: githubHeaders },
430+
),
431+
);
432+
}
433+
434+
const responses = await Promise.all(fetchPromises);
435+
const allStargazers: { username: string; avatar: string }[] = [];
436+
437+
for (const response of responses) {
438+
if (!response.ok) continue;
439+
const data = await response.json();
440+
for (const user of data) {
441+
allStargazers.push({
442+
username: user.login,
443+
avatar: user.avatar_url,
444+
});
445+
}
446+
}
447+
448+
const result = allStargazers.reverse().slice(0, count);
449+
450+
stargazerCache = {
451+
data: result,
452+
timestamp: now,
453+
};
454+
455+
return c.json({ stargazers: result }, 200);
456+
} catch {
457+
if (stargazerCache) {
458+
return c.json({ stargazers: stargazerCache.data }, 200);
459+
}
460+
return c.json({ error: "Failed to fetch stargazers" }, 500);
461+
}
462+
},
463+
);

apps/web/src/components/download-button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function DownloadButton() {
4343
href={href}
4444
download
4545
className={cn([
46-
"group px-6 h-12 flex items-center justify-center text-base sm:text-lg",
46+
"group px-6 h-12 flex items-center justify-center",
4747
"bg-linear-to-t from-stone-600 to-stone-500 text-white rounded-full",
4848
"shadow-md hover:shadow-lg hover:scale-[102%] active:scale-[98%]",
4949
"transition-all",

apps/web/src/queries.ts

Lines changed: 64 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { useQuery } from "@tanstack/react-query";
22

33
const ORG_REPO = "fastrepl/hyprnote";
4-
const LAST_SEEN_STARS = 6419;
5-
const LAST_SEEN_FORKS = 396;
4+
const LAST_SEEN_STARS = 7032;
5+
const LAST_SEEN_FORKS = 432;
66

77
export function useGitHubStats() {
88
return useQuery({
@@ -23,61 +23,73 @@ export interface Stargazer {
2323
avatar: string;
2424
}
2525

26-
export function useGitHubStargazers(count: number = 100) {
27-
return useQuery({
28-
queryKey: ["github-stargazers", count],
29-
queryFn: async (): Promise<Stargazer[]> => {
30-
try {
31-
const repoResponse = await fetch(
32-
`https://api.github.com/repos/${ORG_REPO}`,
33-
{
34-
headers: {
35-
Accept: "application/vnd.github.v3+json",
36-
},
37-
},
38-
);
39-
if (!repoResponse.ok) {
40-
console.error(
41-
`Failed to fetch repo info: ${repoResponse.status} ${repoResponse.statusText}`,
42-
);
43-
return [];
44-
}
45-
const repoData = await repoResponse.json();
46-
const totalStars = repoData.stargazers_count ?? LAST_SEEN_STARS;
26+
async function fetchStargazersFromGitHub(): Promise<Stargazer[]> {
27+
const firstResponse = await fetch(
28+
`https://api.github.com/repos/${ORG_REPO}/stargazers?per_page=100`,
29+
);
30+
if (!firstResponse.ok) return [];
31+
32+
const linkHeader = firstResponse.headers.get("Link");
33+
if (!linkHeader) {
34+
const data = await firstResponse.json();
35+
return data.map((user: { login: string; avatar_url: string }) => ({
36+
username: user.login,
37+
avatar: user.avatar_url,
38+
}));
39+
}
40+
41+
const lastMatch = linkHeader.match(/<([^>]+)>;\s*rel="last"/);
42+
if (!lastMatch) {
43+
const data = await firstResponse.json();
44+
return data.map((user: { login: string; avatar_url: string }) => ({
45+
username: user.login,
46+
avatar: user.avatar_url,
47+
}));
48+
}
49+
50+
const lastPageUrl = new URL(lastMatch[1]);
51+
const lastPage = parseInt(lastPageUrl.searchParams.get("page") || "1", 10);
52+
const secondLastPage = Math.max(1, lastPage - 1);
53+
54+
const [lastResponse, secondLastResponse] = await Promise.all([
55+
fetch(lastPageUrl.toString()),
56+
lastPage > 1
57+
? fetch(
58+
`https://api.github.com/repos/${ORG_REPO}/stargazers?per_page=100&page=${secondLastPage}`,
59+
)
60+
: Promise.resolve(null),
61+
]);
4762

48-
if (totalStars === 0) {
49-
return [];
50-
}
63+
if (!lastResponse.ok) return [];
5164

52-
const perPage = Math.min(count, 100);
53-
const lastPage = Math.ceil(totalStars / perPage);
65+
const lastData = await lastResponse.json();
66+
const secondLastData = secondLastResponse?.ok
67+
? await secondLastResponse.json()
68+
: [];
5469

55-
const response = await fetch(
56-
`https://api.github.com/repos/${ORG_REPO}/stargazers?per_page=${perPage}&page=${lastPage}`,
57-
{
58-
headers: {
59-
Accept: "application/vnd.github.v3+json",
60-
},
61-
},
62-
);
63-
if (!response.ok) {
64-
console.error(
65-
`Failed to fetch stargazers: ${response.status} ${response.statusText}`,
66-
);
67-
return [];
68-
}
70+
const combined = [...secondLastData, ...lastData];
71+
return combined
72+
.reverse()
73+
.slice(0, 200)
74+
.map((user: { login: string; avatar_url: string }) => ({
75+
username: user.login,
76+
avatar: user.avatar_url,
77+
}));
78+
}
79+
80+
export function useGitHubStargazers() {
81+
return useQuery({
82+
queryKey: ["github-stargazers"],
83+
queryFn: async (): Promise<Stargazer[]> => {
84+
const response = await fetch("https://api.hyprnote.com/stargazers").catch(
85+
() => null,
86+
);
87+
if (response?.ok) {
6988
const data = await response.json();
70-
const stargazers = data.map(
71-
(user: { login: string; avatar_url: string }) => ({
72-
username: user.login,
73-
avatar: user.avatar_url,
74-
}),
75-
);
76-
return stargazers.reverse();
77-
} catch (error) {
78-
console.error("Error fetching stargazers:", error);
79-
return [];
89+
return data.stargazers;
8090
}
91+
92+
return fetchStargazersFromGitHub().catch(() => []);
8193
},
8294
staleTime: 1000 * 60 * 60,
8395
});

apps/web/src/routes/_view/opensource.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ function StargazerAvatar({ stargazer }: { stargazer: Stargazer }) {
7676
href={`https://github.com/${stargazer.username}`}
7777
target="_blank"
7878
rel="noopener noreferrer"
79-
className="block size-8 rounded-sm overflow-hidden border border-neutral-200/50 bg-neutral-100 shrink-0 hover:scale-110 hover:border-neutral-400 hover:opacity-100 transition-all"
79+
className="block size-14 rounded-sm overflow-hidden border border-neutral-200/50 bg-neutral-100 shrink-0 hover:scale-110 hover:border-neutral-400 hover:opacity-100 transition-all"
8080
>
8181
<img
8282
src={stargazer.avatar}
@@ -89,8 +89,8 @@ function StargazerAvatar({ stargazer }: { stargazer: Stargazer }) {
8989
}
9090

9191
function StargazersGrid({ stargazers }: { stargazers: Stargazer[] }) {
92-
const rows = 16;
93-
const cols = 32;
92+
const rows = 10;
93+
const cols = 20;
9494

9595
return (
9696
<div className="absolute inset-0 overflow-hidden pointer-events-none">
@@ -100,7 +100,7 @@ function StargazersGrid({ stargazers }: { stargazers: Stargazer[] }) {
100100
{Array.from({ length: cols }).map((_, colIndex) => {
101101
const index = (rowIndex * cols + colIndex) % stargazers.length;
102102
const stargazer = stargazers[index];
103-
const delay = (rowIndex * cols + colIndex) * 0.05;
103+
const delay = Math.random() * 3;
104104

105105
return (
106106
<div
@@ -123,7 +123,7 @@ function StargazersGrid({ stargazers }: { stargazers: Stargazer[] }) {
123123
}
124124

125125
function HeroSection() {
126-
const { data: stargazers = [] } = useGitHubStargazers(500);
126+
const { data: stargazers = [] } = useGitHubStargazers();
127127

128128
return (
129129
<div className="bg-linear-to-b from-stone-50/30 to-stone-100/30 relative overflow-hidden">
@@ -147,7 +147,7 @@ function HeroSection() {
147147
target="_blank"
148148
rel="noopener noreferrer"
149149
className={cn([
150-
"inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium rounded-full",
150+
"inline-flex items-center justify-center gap-2 px-8 py-3 font-medium rounded-full",
151151
"bg-linear-to-t from-neutral-800 to-neutral-700 text-white",
152152
"hover:scale-105 active:scale-95 transition-transform",
153153
])}

0 commit comments

Comments
 (0)