diff --git a/apps/api/src/routes.ts b/apps/api/src/routes.ts index 313569b517..3186822861 100644 --- a/apps/api/src/routes.ts +++ b/apps/api/src/routes.ts @@ -20,8 +20,19 @@ export const API_TAGS = { INTERNAL: "internal", APP: "app", WEBHOOK: "webhook", + PUBLIC: "public", } as const; +const GITHUB_ORG_REPO = "fastrepl/hyprnote"; + +interface StargazerCache { + data: { username: string; avatar: string }[]; + timestamp: number; +} + +let stargazerCache: StargazerCache | null = null; +const CACHE_TTL_MS = 1000 * 60 * 60; + const HealthResponseSchema = z.object({ status: z.string(), }); @@ -31,17 +42,15 @@ const ChatCompletionMessageSchema = z.object({ content: z.string(), }); -const ChatCompletionRequestSchema = z - .object({ - model: z.string().optional(), - messages: z.array(ChatCompletionMessageSchema), - tools: z.array(z.unknown()).optional(), - tool_choice: z.union([z.string(), z.object({})]).optional(), - stream: z.boolean().optional(), - temperature: z.number().optional(), - max_tokens: z.number().optional(), - }) - .passthrough(); +const ChatCompletionRequestSchema = z.looseObject({ + model: z.string().optional(), + messages: z.array(ChatCompletionMessageSchema), + tools: z.array(z.unknown()).optional(), + tool_choice: z.union([z.string(), z.object({})]).optional(), + stream: z.boolean().optional(), + temperature: z.number().optional(), + max_tokens: z.number().optional(), +}); const WebhookSuccessSchema = z.object({ ok: z.boolean(), @@ -337,3 +346,118 @@ routes.get( return listenSocketHandler(c, next); }, ); + +const StargazersResponseSchema = z.object({ + stargazers: z.array( + z.object({ + username: z.string(), + avatar: z.string(), + }), + ), +}); + +routes.get( + "/stargazers", + describeRoute({ + tags: [API_TAGS.PUBLIC], + summary: "Get GitHub stargazers", + description: + "Returns the most recent GitHub stargazers for the Hyprnote repository. Results are cached for 1 hour.", + responses: { + 200: { + description: "List of stargazers", + content: { + "application/json": { + schema: resolver(StargazersResponseSchema), + }, + }, + }, + 500: { + description: "Failed to fetch stargazers from GitHub", + content: { + "application/json": { + schema: resolver(z.object({ error: z.string() })), + }, + }, + }, + }, + }), + async (c) => { + const now = Date.now(); + + if (stargazerCache && now - stargazerCache.timestamp < CACHE_TTL_MS) { + return c.json({ stargazers: stargazerCache.data }, 200); + } + + const githubToken = process.env.GITHUB_TOKEN; + const githubHeaders: Record = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Hyprnote-API", + }; + if (githubToken) { + githubHeaders["Authorization"] = `token ${githubToken}`; + } + + try { + const repoResponse = await fetch( + `https://api.github.com/repos/${GITHUB_ORG_REPO}`, + { headers: githubHeaders }, + ); + + if (!repoResponse.ok) { + throw new Error(`Failed to fetch repo info: ${repoResponse.status}`); + } + + const repoData = await repoResponse.json(); + const totalStars = repoData.stargazers_count ?? 0; + + if (totalStars === 0) { + return c.json({ stargazers: [] }, 200); + } + + const count = 512; + const perPage = 100; + const numPages = Math.ceil(Math.min(count, totalStars) / perPage); + const lastPage = Math.ceil(totalStars / perPage); + const startPage = Math.max(1, lastPage - numPages + 1); + + const fetchPromises = []; + for (let page = startPage; page <= lastPage; page++) { + fetchPromises.push( + fetch( + `https://api.github.com/repos/${GITHUB_ORG_REPO}/stargazers?per_page=${perPage}&page=${page}`, + { headers: githubHeaders }, + ), + ); + } + + const responses = await Promise.all(fetchPromises); + const allStargazers: { username: string; avatar: string }[] = []; + + for (const response of responses) { + if (!response.ok) continue; + const data = await response.json(); + for (const user of data) { + allStargazers.push({ + username: user.login, + avatar: user.avatar_url, + }); + } + } + + const result = allStargazers.reverse().slice(0, count); + + stargazerCache = { + data: result, + timestamp: now, + }; + + return c.json({ stargazers: result }, 200); + } catch { + if (stargazerCache) { + return c.json({ stargazers: stargazerCache.data }, 200); + } + return c.json({ error: "Failed to fetch stargazers" }, 500); + } + }, +); diff --git a/apps/web/src/components/download-button.tsx b/apps/web/src/components/download-button.tsx index 2bffae0b05..b0e61592a4 100644 --- a/apps/web/src/components/download-button.tsx +++ b/apps/web/src/components/download-button.tsx @@ -43,7 +43,7 @@ export function DownloadButton() { href={href} download className={cn([ - "group px-6 h-12 flex items-center justify-center text-base sm:text-lg", + "group px-6 h-12 flex items-center justify-center", "bg-linear-to-t from-stone-600 to-stone-500 text-white rounded-full", "shadow-md hover:shadow-lg hover:scale-[102%] active:scale-[98%]", "transition-all", diff --git a/apps/web/src/queries.ts b/apps/web/src/queries.ts index ddfd13652b..ad3a1b340b 100644 --- a/apps/web/src/queries.ts +++ b/apps/web/src/queries.ts @@ -1,8 +1,8 @@ import { useQuery } from "@tanstack/react-query"; const ORG_REPO = "fastrepl/hyprnote"; -const LAST_SEEN_STARS = 6419; -const LAST_SEEN_FORKS = 396; +const LAST_SEEN_STARS = 7032; +const LAST_SEEN_FORKS = 432; export function useGitHubStats() { return useQuery({ @@ -23,61 +23,73 @@ export interface Stargazer { avatar: string; } -export function useGitHubStargazers(count: number = 100) { - return useQuery({ - queryKey: ["github-stargazers", count], - queryFn: async (): Promise => { - try { - const repoResponse = await fetch( - `https://api.github.com/repos/${ORG_REPO}`, - { - headers: { - Accept: "application/vnd.github.v3+json", - }, - }, - ); - if (!repoResponse.ok) { - console.error( - `Failed to fetch repo info: ${repoResponse.status} ${repoResponse.statusText}`, - ); - return []; - } - const repoData = await repoResponse.json(); - const totalStars = repoData.stargazers_count ?? LAST_SEEN_STARS; +async function fetchStargazersFromGitHub(): Promise { + const firstResponse = await fetch( + `https://api.github.com/repos/${ORG_REPO}/stargazers?per_page=100`, + ); + if (!firstResponse.ok) return []; + + const linkHeader = firstResponse.headers.get("Link"); + if (!linkHeader) { + const data = await firstResponse.json(); + return data.map((user: { login: string; avatar_url: string }) => ({ + username: user.login, + avatar: user.avatar_url, + })); + } + + const lastMatch = linkHeader.match(/<([^>]+)>;\s*rel="last"/); + if (!lastMatch) { + const data = await firstResponse.json(); + return data.map((user: { login: string; avatar_url: string }) => ({ + username: user.login, + avatar: user.avatar_url, + })); + } + + const lastPageUrl = new URL(lastMatch[1]); + const lastPage = parseInt(lastPageUrl.searchParams.get("page") || "1", 10); + const secondLastPage = Math.max(1, lastPage - 1); + + const [lastResponse, secondLastResponse] = await Promise.all([ + fetch(lastPageUrl.toString()), + lastPage > 1 + ? fetch( + `https://api.github.com/repos/${ORG_REPO}/stargazers?per_page=100&page=${secondLastPage}`, + ) + : Promise.resolve(null), + ]); - if (totalStars === 0) { - return []; - } + if (!lastResponse.ok) return []; - const perPage = Math.min(count, 100); - const lastPage = Math.ceil(totalStars / perPage); + const lastData = await lastResponse.json(); + const secondLastData = secondLastResponse?.ok + ? await secondLastResponse.json() + : []; - const response = await fetch( - `https://api.github.com/repos/${ORG_REPO}/stargazers?per_page=${perPage}&page=${lastPage}`, - { - headers: { - Accept: "application/vnd.github.v3+json", - }, - }, - ); - if (!response.ok) { - console.error( - `Failed to fetch stargazers: ${response.status} ${response.statusText}`, - ); - return []; - } + const combined = [...secondLastData, ...lastData]; + return combined + .reverse() + .slice(0, 200) + .map((user: { login: string; avatar_url: string }) => ({ + username: user.login, + avatar: user.avatar_url, + })); +} + +export function useGitHubStargazers() { + return useQuery({ + queryKey: ["github-stargazers"], + queryFn: async (): Promise => { + const response = await fetch("https://api.hyprnote.com/stargazers").catch( + () => null, + ); + if (response?.ok) { const data = await response.json(); - const stargazers = data.map( - (user: { login: string; avatar_url: string }) => ({ - username: user.login, - avatar: user.avatar_url, - }), - ); - return stargazers.reverse(); - } catch (error) { - console.error("Error fetching stargazers:", error); - return []; + return data.stargazers; } + + return fetchStargazersFromGitHub().catch(() => []); }, staleTime: 1000 * 60 * 60, }); diff --git a/apps/web/src/routes/_view/opensource.tsx b/apps/web/src/routes/_view/opensource.tsx index ad49bfaf9a..7b05cb563f 100644 --- a/apps/web/src/routes/_view/opensource.tsx +++ b/apps/web/src/routes/_view/opensource.tsx @@ -76,7 +76,7 @@ function StargazerAvatar({ stargazer }: { stargazer: Stargazer }) { href={`https://github.com/${stargazer.username}`} target="_blank" rel="noopener noreferrer" - 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" + 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" > @@ -100,7 +100,7 @@ function StargazersGrid({ stargazers }: { stargazers: Stargazer[] }) { {Array.from({ length: cols }).map((_, colIndex) => { const index = (rowIndex * cols + colIndex) % stargazers.length; const stargazer = stargazers[index]; - const delay = (rowIndex * cols + colIndex) * 0.05; + const delay = Math.random() * 3; return (
@@ -147,7 +147,7 @@ function HeroSection() { target="_blank" rel="noopener noreferrer" className={cn([ - "inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium rounded-full", + "inline-flex items-center justify-center gap-2 px-8 py-3 font-medium rounded-full", "bg-linear-to-t from-neutral-800 to-neutral-700 text-white", "hover:scale-105 active:scale-95 transition-transform", ])}