Add Projects page backed by the Forgejo API, with caching

This commit is contained in:
badblocks 2026-07-20 12:10:32 -07:00
parent 5d5fb93647
commit 8466324a59
Signed by: badblocks
SSH key fingerprint: SHA256:hEcM6BP4hKm9F7WsomNuXSBpPn2BSDnFzPSl3y1NerQ
5 changed files with 269 additions and 1 deletions

View file

@ -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", " ");
---
<style>
.project-card {
border: 1px solid var(--color-accent);
padding: var(--gutter-small) var(--gutter-large);
display: flex; flex-direction: column; gap: var(--stack-small);
box-shadow: 0 0 6px var(--color-accent-transparent);
}
.project-card:hover { box-shadow: 0 0 12px var(--color-accent-transparent); }
.project-card header { text-align: left; }
.project-meta { color: var(--color-mg); font-size: var(--pt-small-pica); display: flex; flex-wrap: wrap; gap: var(--gutter-small); }
.last-commit { color: var(--color-mg); font-size: var(--pt-long-primer); }
.last-commit .sha { color: var(--color-accent-bright); }
.lang-bar { display: flex; block-size: 0.75ex; border: 1px solid var(--color-mg); }
.lang-seg { inline-size: var(--w); background: var(--color-accent); }
.lang-seg:nth-child(2n) { background: var(--color-accent-bright); }
.lang-seg:nth-child(3n) { background: var(--color-mg); }
.lang-legend, .topic-list { font-size: var(--pt-long-primer); display: flex; flex-wrap: wrap; gap: var(--gutter-small); }
.topic-list { list-style: none; padding: 0; color: var(--color-accent-bright); }
:root[data-more-contrast="on"] .project-card { box-shadow: none; border-color: var(--color-fg); }
:root[data-more-contrast="on"] :is(.project-meta, .last-commit) { color: var(--color-fg); }
@media (prefers-contrast: more) {
:root:not([data-more-contrast="off"]) .project-card { box-shadow: none; border-color: var(--color-fg); }
:root:not([data-more-contrast="off"]) :is(.project-meta, .last-commit) { color: var(--color-fg); }
}
</style>
<article class="project-card">
<header>
<h3>
<a href={project.url} target="_blank" rel="noopener">{project.name}</a>
{project.archived && <span class="badge badge-dim">[archived]</span>}
</h3>
<p class="project-meta">
<span title="Stars">★ {project.stars}</span>
<span title="Forks">⑂ {project.forks}</span>
<time datetime={project.lastCommitAt}>{committedAtLabel}</time>
</p>
</header>
{project.description && <p class="project-desc">{project.description}</p>}
{project.lastCommitMessage && (
<p class="last-commit">
<span class="sha">{project.lastCommitSha}</span> {project.lastCommitMessage}
</p>
)}
{project.languages.length > 0 && (
<div class="lang-bar" role="img" aria-label={`Languages: ${project.languages.map((l) => `${l.name} ${l.percent}%`).join(", ")}`}>
{project.languages.map((l) => (
<span class="lang-seg" style={`--w: ${l.percent}%`} title={`${l.name} ${l.percent}%`} />
))}
</div>
<p class="lang-legend">
{project.languages.slice(0, 4).map((l) => <span>{l.name} {l.percent}%</span>)}
</p>
)}
{project.topics.length > 0 && (
<ul class="topic-list" aria-label="Topics">
{project.topics.map((t) => <li>#{t}</li>)}
</ul>
)}
</article>

View file

@ -44,6 +44,7 @@
<ul>
<li><a href="/">Home</a></li>
<li><a href="/contact">Contact</a></li>
<li><a href="/projects">Projects</a></li>
<li><a href="https://git.badblocks.dev" target="_blank">Git</a></li>
</ul>
</nav>
@ -106,7 +107,7 @@
/>
</a>
<br />
Made from scratch with BAHz: Bun, Astro, and Htmz!
Made from scratch with BAAHz: Bun, Astro, Alpine.js, and Htmz!
</p>
</footer>
</body>

52
src/lib/ForgejoClient.ts Normal file
View file

@ -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<ForgejoRepoDto[]> => {
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<Record<string, number>> =>
httpFetchClient.get(`${API}/repos/${fullName}/languages`, HEADERS),
getLastCommit: async (fullName: string, branch: string): Promise<LastCommit | null> => {
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;

78
src/lib/ProjectsCache.ts Normal file
View file

@ -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<ProjectsSnapshot> | null = null;
export async function getProjects(): Promise<ProjectsSnapshot> {
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<ProjectsSnapshot> {
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<string, number>,
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,
};
}

64
src/pages/projects.astro Normal file
View file

@ -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();
---
<Layout>
<title slot="head">Projects | badblocks.dev</title>
<style slot="head">
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 34ch), 1fr));
gap: var(--gutter-large);
margin-block-start: var(--stack-large);
}
.stale-note,
.empty-state {
color: var(--color-accent-bright);
}
</style>
<Fragment slot="main">
<section class="projects">
<PageHeader path="~/projects" command="ls -la --sort=committed" />
{
stale && projects.length > 0 && (
<p class="stale-note" role="status">
[!] showing cached data — git.badblocks.dev unreachable
</p>
)
}
{
projects.length === 0 && (
<div class="empty-state">
{stale ? (
<p>
[!] could not reach{" "}
<a href="https://git.badblocks.dev">git.badblocks.dev</a> —
projects live there; try again shortly.
</p>
) : (
<p>
total 0 — nothing tagged <code>#showcase</code> yet. Browse
everything at{" "}
<a href="https://git.badblocks.dev/badblocks">
git.badblocks.dev
</a>
.
</p>
)}
</div>
)
}
<div class="projects-grid">
{projects.map((p) => <ProjectCard project={p} />)}
</div>
</section>
</Fragment>
</Layout>