Compare commits

..

3 commits

10 changed files with 372 additions and 28 deletions

View file

@ -356,28 +356,32 @@ nav {
justify-content: flex-end; justify-content: flex-end;
list-style: none; list-style: none;
padding-block: var(--stack-small); padding-block: var(--stack-small);
padding-inline: 1ch; padding-inline: 0;
} }
.display-prefs-nav summary::-webkit-details-marker { .display-prefs-nav summary::-webkit-details-marker {
display: none; display: none;
} }
.display-prefs-nav summary:hover { .display-prefs-nav summary::before {
padding-inline: 0; color: transparent;
text-shadow: none;
content: "[" / "";
} }
.display-prefs-nav summary:hover::before { .display-prefs-nav summary::after {
color: var(--color-glow); color: transparent;
content: "["; text-shadow: none;
content: "]" / "";
} }
.display-prefs-nav summary:hover::before,
.display-prefs-nav summary:hover::after { .display-prefs-nav summary:hover::after {
color: var(--color-glow); color: var(--color-glow);
content: "]"; text-shadow: inherit;
} }
.display-prefs-nav summary > span { .display-prefs-nav summary > span {
translate: 0 -0.225em; translate: 0 -0.225em;
} }
.display-prefs-panel { .display-prefs-panel {
background-color: var(--color-bg); background-color: var(--color-bg);
border: 2px solid var(--color-accent); border: 1px solid var(--color-accent);
min-inline-size: 10em; min-inline-size: 10em;
padding-block: var(--stack-small); padding-block: var(--stack-small);
padding-inline: var(--gutter-small); padding-inline: var(--gutter-small);
@ -414,21 +418,30 @@ form p {
a { a {
color: var(--color-fg); color: var(--color-fg);
margin: 0 -1ch; margin: 0 -1ch;
padding: 0 1ch;
text-decoration: underline; text-decoration: underline;
text-decoration-color: var(--color-glow); text-decoration-color: var(--color-glow);
&:hover::before { /* Brackets always occupy their space and hover only reveals them. Swapping
color: var(--color-glow); ch-based padding for a rendered glyph shifts the text by a subpixel,
content: "["; because the fluid root font-size makes 1ch fractional.
The / "" keeps them out of the accessible name. */
&::before {
color: transparent;
text-shadow: none;
content: "[" / "";
} }
&::after {
color: transparent;
text-shadow: none;
content: "]" / "";
}
&:hover::before,
&:hover::after { &:hover::after {
color: var(--color-glow); color: var(--color-glow);
content: "]"; text-shadow: inherit;
} }
&:hover { &:hover {
color: var(--color-fg); color: var(--color-fg);
padding: 0;
text-decoration: none; text-decoration: none;
} }
} }
@ -439,10 +452,8 @@ footer a {
margin: 0 auto; margin: 0 auto;
padding: 0; padding: 0;
&:hover::before { &::before,
content: none; &::after {
}
&:hover::after {
content: none; content: none;
} }
} }

View file

@ -0,0 +1,64 @@
---
interface Props {
/** Shell-style path shown as the heading, e.g. "~/projects" */
path: string;
/** Command echoed under the heading, without the "$ " prompt */
command?: string;
/** Heading level; pages with their own <h1> elsewhere should stay at 2 */
level?: 1 | 2;
}
const { path, command, level = 2 } = Astro.props;
const Heading = level === 1 ? "h1" : "h2";
---
<style>
.page-header {
margin-block-end: var(--stack-large);
}
.listing-line {
color: var(--color-mg);
font-size: var(--pt-small-pica);
}
.prompt-cursor {
margin-inline-start: 0.667em;
animation: blink 1.2s steps(1) infinite;
color: var(--color-accent);
}
@keyframes blink {
50% {
opacity: 0;
}
}
:root[data-reduce-motion="on"] .prompt-cursor {
animation: none;
}
@media (prefers-reduced-motion: reduce) {
:root:not([data-reduce-motion="off"]) .prompt-cursor {
animation: none;
}
}
:root[data-more-contrast="on"] .listing-line {
color: var(--color-fg);
}
@media (prefers-contrast: more) {
:root:not([data-more-contrast="off"]) .listing-line {
color: var(--color-fg);
}
}
</style>
<hgroup class="page-header">
<Heading>
{path}
<span class="prompt-cursor" aria-hidden="true">█</span>
</Heading>
{
command && (
<p class="listing-line" aria-hidden="true">
$ {command}
</p>
)
}
</hgroup>

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

View file

@ -1,5 +1,6 @@
--- ---
import Layout from "@layouts/BaseLayout.astro"; import Layout from "@layouts/BaseLayout.astro";
import PageHeader from "@components/PageHeader.astro";
import { actions, isInputError } from "astro:actions"; import { actions, isInputError } from "astro:actions";
export const prerender = false; export const prerender = false;
@ -147,7 +148,7 @@ const msgValue = pickValue("msg") || (await Astro.session?.get("msg"));
</style> </style>
</Fragment> </Fragment>
<Fragment slot="main"> <Fragment slot="main">
<h2>Contact</h2> <PageHeader path="~/contact" command="mail badblocks" />
{ {
(nextAction != "complete" && ( (nextAction != "complete" && (
<form method="post" x-data="{}" action={actions.contact.submitForm}> <form method="post" x-data="{}" action={actions.contact.submitForm}>

View file

@ -1,5 +1,6 @@
--- ---
import Layout from "@layouts/BaseLayout.astro"; import Layout from "@layouts/BaseLayout.astro";
import PageHeader from "@components/PageHeader.astro";
import fs from "node:fs"; import fs from "node:fs";
const txtToHtml = (string: string) => { const txtToHtml = (string: string) => {
@ -23,13 +24,11 @@ const license_file_contents = txtToHtml(
<Layout> <Layout>
<title slot="head">LICENSE | badblocks.dev</title> <title slot="head">LICENSE | badblocks.dev</title>
<Fragment slot="main"> <Fragment slot="main">
<header> <PageHeader path="~/license" command="cat LICENSE" level={1} />
<h1> <p>
<a href="https://opensource.org/license/mit" target="_blank" <a href="https://opensource.org/license/mit" target="_blank">MIT License</a
>MIT License</a >
> </p>
</h1>
</header>
<div style="font-size: medium;" set:html={license_file_contents} /> <div style="font-size: medium;" set:html={license_file_contents} />
</Fragment> </Fragment>
</Layout> </Layout>

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>

View file

@ -1,5 +1,6 @@
--- ---
import Layout from "@layouts/BaseLayout.astro"; import Layout from "@layouts/BaseLayout.astro";
import PageHeader from "@components/PageHeader.astro";
--- ---
<Layout> <Layout>
@ -15,7 +16,7 @@ import Layout from "@layouts/BaseLayout.astro";
</style> </style>
<Fragment slot="main"> <Fragment slot="main">
<section class="tools" id="tools"> <section class="tools" id="tools">
<h2>Tools</h2> <PageHeader path="~/tools" command="ls" />
<div class="tools-grid"> <div class="tools-grid">
<div class="tool-card">Tool 1</div> <div class="tool-card">Tool 1</div>
<div class="tool-card">Tool 2</div> <div class="tool-card">Tool 2</div>