Compare commits
3 commits
c959f0d848
...
89cfe7bdec
| Author | SHA1 | Date | |
|---|---|---|---|
| 89cfe7bdec | |||
| 8466324a59 | |||
| 5d5fb93647 |
10 changed files with 372 additions and 28 deletions
|
|
@ -356,28 +356,32 @@ nav {
|
|||
justify-content: flex-end;
|
||||
list-style: none;
|
||||
padding-block: var(--stack-small);
|
||||
padding-inline: 1ch;
|
||||
padding-inline: 0;
|
||||
}
|
||||
.display-prefs-nav summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.display-prefs-nav summary:hover {
|
||||
padding-inline: 0;
|
||||
.display-prefs-nav summary::before {
|
||||
color: transparent;
|
||||
text-shadow: none;
|
||||
content: "[" / "";
|
||||
}
|
||||
.display-prefs-nav summary:hover::before {
|
||||
color: var(--color-glow);
|
||||
content: "[";
|
||||
.display-prefs-nav summary::after {
|
||||
color: transparent;
|
||||
text-shadow: none;
|
||||
content: "]" / "";
|
||||
}
|
||||
.display-prefs-nav summary:hover::before,
|
||||
.display-prefs-nav summary:hover::after {
|
||||
color: var(--color-glow);
|
||||
content: "]";
|
||||
text-shadow: inherit;
|
||||
}
|
||||
.display-prefs-nav summary > span {
|
||||
translate: 0 -0.225em;
|
||||
}
|
||||
.display-prefs-panel {
|
||||
background-color: var(--color-bg);
|
||||
border: 2px solid var(--color-accent);
|
||||
border: 1px solid var(--color-accent);
|
||||
min-inline-size: 10em;
|
||||
padding-block: var(--stack-small);
|
||||
padding-inline: var(--gutter-small);
|
||||
|
|
@ -414,21 +418,30 @@ form p {
|
|||
a {
|
||||
color: var(--color-fg);
|
||||
margin: 0 -1ch;
|
||||
padding: 0 1ch;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--color-glow);
|
||||
|
||||
&:hover::before {
|
||||
color: var(--color-glow);
|
||||
content: "[";
|
||||
/* Brackets always occupy their space and hover only reveals them. Swapping
|
||||
ch-based padding for a rendered glyph shifts the text by a subpixel,
|
||||
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 {
|
||||
color: var(--color-glow);
|
||||
content: "]";
|
||||
text-shadow: inherit;
|
||||
}
|
||||
&:hover {
|
||||
color: var(--color-fg);
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -439,10 +452,8 @@ footer a {
|
|||
margin: 0 auto;
|
||||
padding: 0;
|
||||
|
||||
&:hover::before {
|
||||
content: none;
|
||||
}
|
||||
&:hover::after {
|
||||
&::before,
|
||||
&::after {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
64
src/components/PageHeader.astro
Normal file
64
src/components/PageHeader.astro
Normal 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>
|
||||
73
src/components/ProjectCard.astro
Normal file
73
src/components/ProjectCard.astro
Normal 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>
|
||||
|
|
@ -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
52
src/lib/ForgejoClient.ts
Normal 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
78
src/lib/ProjectsCache.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
import Layout from "@layouts/BaseLayout.astro";
|
||||
import PageHeader from "@components/PageHeader.astro";
|
||||
import { actions, isInputError } from "astro:actions";
|
||||
export const prerender = false;
|
||||
|
||||
|
|
@ -147,7 +148,7 @@ const msgValue = pickValue("msg") || (await Astro.session?.get("msg"));
|
|||
</style>
|
||||
</Fragment>
|
||||
<Fragment slot="main">
|
||||
<h2>Contact</h2>
|
||||
<PageHeader path="~/contact" command="mail badblocks" />
|
||||
{
|
||||
(nextAction != "complete" && (
|
||||
<form method="post" x-data="{}" action={actions.contact.submitForm}>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
import Layout from "@layouts/BaseLayout.astro";
|
||||
import PageHeader from "@components/PageHeader.astro";
|
||||
import fs from "node:fs";
|
||||
|
||||
const txtToHtml = (string: string) => {
|
||||
|
|
@ -23,13 +24,11 @@ const license_file_contents = txtToHtml(
|
|||
<Layout>
|
||||
<title slot="head">LICENSE | badblocks.dev</title>
|
||||
<Fragment slot="main">
|
||||
<header>
|
||||
<h1>
|
||||
<a href="https://opensource.org/license/mit" target="_blank"
|
||||
>MIT License</a
|
||||
<PageHeader path="~/license" command="cat LICENSE" level={1} />
|
||||
<p>
|
||||
<a href="https://opensource.org/license/mit" target="_blank">MIT License</a
|
||||
>
|
||||
</h1>
|
||||
</header>
|
||||
</p>
|
||||
<div style="font-size: medium;" set:html={license_file_contents} />
|
||||
</Fragment>
|
||||
</Layout>
|
||||
|
|
|
|||
64
src/pages/projects.astro
Normal file
64
src/pages/projects.astro
Normal 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>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
import Layout from "@layouts/BaseLayout.astro";
|
||||
import PageHeader from "@components/PageHeader.astro";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
|
|
@ -15,7 +16,7 @@ import Layout from "@layouts/BaseLayout.astro";
|
|||
</style>
|
||||
<Fragment slot="main">
|
||||
<section class="tools" id="tools">
|
||||
<h2>Tools</h2>
|
||||
<PageHeader path="~/tools" command="ls" />
|
||||
<div class="tools-grid">
|
||||
<div class="tool-card">Tool 1</div>
|
||||
<div class="tool-card">Tool 2</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue