diff --git a/src/components/ProjectCard.astro b/src/components/ProjectCard.astro new file mode 100644 index 0000000..92afeec --- /dev/null +++ b/src/components/ProjectCard.astro @@ -0,0 +1,73 @@ +--- +import type { Project } from "@lib/ProjectsCache"; +interface Props { project: Project } +const { project } = Astro.props; +// Space instead of "T" gives the browser somewhere to wrap; the datetime +// attribute keeps the real ISO value. +const committedAtLabel = project.lastCommitAt.replace("T", " "); +--- + +
+
+

+ {project.name} + {project.archived && [archived]} +

+

+ ★ {project.stars} + ⑂ {project.forks} + +

+
+ + {project.description &&

{project.description}

} + {project.lastCommitMessage && ( +

+ {project.lastCommitSha} {project.lastCommitMessage} +

+ )} + + {project.languages.length > 0 && ( + +

+ {project.languages.slice(0, 4).map((l) => {l.name} {l.percent}%)} +

+ )} + + {project.topics.length > 0 && ( + + )} +
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 9f3eadd..fbbfc67 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -44,6 +44,7 @@ @@ -106,7 +107,7 @@ />
- Made from scratch with BAHz: Bun, Astro, and Htmz! + Made from scratch with BAAHz: Bun, Astro, Alpine.js, and Htmz!

diff --git a/src/lib/ForgejoClient.ts b/src/lib/ForgejoClient.ts new file mode 100644 index 0000000..ed65ad2 --- /dev/null +++ b/src/lib/ForgejoClient.ts @@ -0,0 +1,52 @@ +import httpFetchClient from "@lib/HttpFetchClient"; + + const FORGEJO_BASE = "https://git.badblocks.dev"; + const API = `${FORGEJO_BASE}/api/v1`; + const HEADERS = { Accept: "application/json" }; + + export interface ForgejoRepoDto { + name: string; + full_name: string; + description: string; + topics: string[]; + stars_count: number; + forks_count: number; + updated_at: string; + language: string; + html_url: string; + archived: boolean; + default_branch: string; + } + + export interface LastCommit { date: string; sha: string; message: string } + + export const toIsoSeconds = (ts: string) => + new Date(ts).toISOString().replace(/\.\d{3}Z$/, ""); + + const forgejoClient = { + searchByTopic: async (topic: string): Promise => { + const res = await httpFetchClient.get( + `${API}/repos/search?topic=true&q=${encodeURIComponent(topic)}&limit=50`, HEADERS); + if (!res?.ok) throw new Error("Forgejo search returned not-ok"); + return res.data as ForgejoRepoDto[]; + }, + getLanguages: async (fullName: string): Promise> => + httpFetchClient.get(`${API}/repos/${fullName}/languages`, HEADERS), + getLastCommit: async (fullName: string, branch: string): Promise => { + try { + const res = await httpFetchClient.get( + `${API}/repos/${fullName}/branches/${encodeURIComponent(branch)}`, HEADERS); + const c = res?.commit; + if (!c?.timestamp) return null; + return { + date: toIsoSeconds(c.timestamp), + sha: String(c.id ?? "").slice(0, 7), + message: String(c.message ?? "").split("\n")[0].trim(), + }; + } catch (error: any) { + if (error?.status === 404 || error?.statusCode === 404) return null; + throw error; + } + }, + }; + export default forgejoClient; diff --git a/src/lib/ProjectsCache.ts b/src/lib/ProjectsCache.ts new file mode 100644 index 0000000..c15ad50 --- /dev/null +++ b/src/lib/ProjectsCache.ts @@ -0,0 +1,78 @@ + export interface ProjectLanguage { name: string; bytes: number; percent: number } + + export interface Project { + name: string; fullName: string; description: string; url: string; + topics: string[]; + stars: number; forks: number; + lastCommitAt: string; + lastCommitSha: string | null; + lastCommitMessage: string | null; + languages: ProjectLanguage[]; + archived: boolean; + } + + export interface ProjectsSnapshot { + projects: Project[]; + fetchedAt: string; + stale: boolean; + } + import forgejoClient, { toIsoSeconds, type ForgejoRepoDto, type LastCommit } from "@lib/ForgejoClient"; + + const SHOWCASE_TOPIC = "showcase"; + const TTL_MS = 24 * 60 * 60 * 1000; + + let snapshot: ProjectsSnapshot | null = null; + let inFlight: Promise | null = null; + + export async function getProjects(): Promise { + if (snapshot && Date.now() - Date.parse(snapshot.fetchedAt) < TTL_MS) return snapshot; + if (inFlight) return inFlight; // dedupe concurrent refreshes + inFlight = refresh() + .catch((err) => { + console.error("[projects] Forgejo refresh failed:", err?.message ?? err); + if (snapshot) return (snapshot = { ...snapshot, stale: true }); + return { projects: [], fetchedAt: new Date(0).toISOString(), stale: true }; + }) + .finally(() => { inFlight = null; }); + return inFlight; + } + + async function refresh(): Promise { + const repos = await forgejoClient.searchByTopic(SHOWCASE_TOPIC); + const projects = await Promise.all( + repos.map(async (repo) => { + // allSettled: a flaky enrichment call degrades that field, not the page + const [langs, commit] = await Promise.allSettled([ + forgejoClient.getLanguages(repo.full_name), + forgejoClient.getLastCommit(repo.full_name, repo.default_branch), + ]); + return normalize(repo, + langs.status === "fulfilled" ? langs.value : {}, + commit.status === "fulfilled" ? commit.value : null); + }), + ); + projects.sort((a, b) => + Date.parse(b.lastCommitAt) - Date.parse(a.lastCommitAt)); + return (snapshot = { projects, fetchedAt: new Date().toISOString(), stale: false }); + } + + function normalize( + repo: ForgejoRepoDto, langBytes: Record, + commit: LastCommit | null, + ): Project { + const total = Object.values(langBytes).reduce((s, n) => s + n, 0); + return { + name: repo.name, fullName: repo.full_name, + description: repo.description ?? "", url: repo.html_url, + topics: (repo.topics ?? []).filter((t) => t !== SHOWCASE_TOPIC), + stars: repo.stars_count, forks: repo.forks_count, + // updated_at only as a last resort: it tracks metadata edits, not commits + lastCommitAt: commit?.date ?? toIsoSeconds(repo.updated_at), + lastCommitSha: commit?.sha ?? null, + lastCommitMessage: commit?.message ?? null, + languages: Object.entries(langBytes) + .map(([name, bytes]) => ({ name, bytes, percent: total ? Math.round((bytes / total) * 1000) / 10 : 0 })) + .sort((a, b) => b.bytes - a.bytes), + archived: repo.archived, + }; + } diff --git a/src/pages/projects.astro b/src/pages/projects.astro new file mode 100644 index 0000000..da5d917 --- /dev/null +++ b/src/pages/projects.astro @@ -0,0 +1,64 @@ +--- +import Layout from "@layouts/BaseLayout.astro"; +import ProjectCard from "@components/ProjectCard.astro"; +import PageHeader from "@components/PageHeader.astro"; +import { getProjects } from "@lib/ProjectsCache"; +export const prerender = false; + +const { projects, stale } = await getProjects(); +--- + + + Projects | badblocks.dev + + +
+ + + { + stale && projects.length > 0 && ( +

+ [!] showing cached data — git.badblocks.dev unreachable +

+ ) + } + { + projects.length === 0 && ( +
+ {stale ? ( +

+ [!] could not reach{" "} + git.badblocks.dev — + projects live there; try again shortly. +

+ ) : ( +

+ total 0 — nothing tagged #showcase yet. Browse + everything at{" "} + + git.badblocks.dev + + . +

+ )} +
+ ) + } + +
+ {projects.map((p) => )} +
+
+
+