Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 135 additions & 11 deletions apps/api/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand All @@ -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(),
Expand Down Expand Up @@ -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<string, string> = {
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);
}
},
);
2 changes: 1 addition & 1 deletion apps/web/src/components/download-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
116 changes: 64 additions & 52 deletions apps/web/src/queries.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -23,61 +23,73 @@ export interface Stargazer {
avatar: string;
}

export function useGitHubStargazers(count: number = 100) {
return useQuery({
queryKey: ["github-stargazers", count],
queryFn: async (): Promise<Stargazer[]> => {
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<Stargazer[]> {
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<Stargazer[]> => {
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,
});
Expand Down
12 changes: 6 additions & 6 deletions apps/web/src/routes/_view/opensource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<img
src={stargazer.avatar}
Expand All @@ -89,8 +89,8 @@ function StargazerAvatar({ stargazer }: { stargazer: Stargazer }) {
}

function StargazersGrid({ stargazers }: { stargazers: Stargazer[] }) {
const rows = 16;
const cols = 32;
const rows = 10;
const cols = 20;

return (
<div className="absolute inset-0 overflow-hidden pointer-events-none">
Expand 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 (
<div
Expand All @@ -123,7 +123,7 @@ function StargazersGrid({ stargazers }: { stargazers: Stargazer[] }) {
}

function HeroSection() {
const { data: stargazers = [] } = useGitHubStargazers(500);
const { data: stargazers = [] } = useGitHubStargazers();

return (
<div className="bg-linear-to-b from-stone-50/30 to-stone-100/30 relative overflow-hidden">
Expand All @@ -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",
])}
Expand Down