Files
jeanlucmakiola.de/src/pages/work/[slug].astro
T
makiolaj ff3c0fa437 Point sources at Codeberg, add the Play button, refresh descriptions
Codeberg is where the apps live now, so the source link, its icon and the
release lookup follow. Forgejo serves Gitea's /api/v1, so the version badge
keeps working — with a fallback to the newest pre-release, since a pre-1.0
app publishes nothing else (Agendula showed a stale v0.2.0 before).

Calendula is on Google Play as well as F-Droid; adds a tonal button for it,
and its fifteen community translations replace "German and English".

Agendula's entry described the app it stopped being: it now carries its own
VTODO store, with an external provider as a choice rather than a requirement,
plus export and its own CalDAV sync in progress. floret-kit's modules have
grown past core-time. Footer forge link and the /uses infrastructure list
separate the two roles: Codeberg for source, self-hosted Gitea for builds.
2026-09-09 16:09:30 +02:00

157 lines
5.0 KiB
Plaintext

---
import { getCollection, render } from 'astro:content';
import { Icon } from 'astro-icon/components';
import BaseLayout from '../../layouts/BaseLayout.astro';
import type { GetStaticPaths } from 'astro';
export const getStaticPaths = (async () => {
// Build-time: resolve the latest release tag from the project's Codeberg
// repo. Forgejo serves Gitea's /api/v1, so the same call fits both.
// `releases/latest` skips pre-releases, and a pre-1.0 app publishes nothing
// else — so fall back to the newest release of any kind before giving up.
// Never throws — returns null on 404/offline so the build can't break.
// (Defined inside getStaticPaths: Astro extracts this fn into its own scope.)
const fetchLatestRelease = async (repoUrl?: string): Promise<string | null> => {
if (!repoUrl) return null;
const tagFrom = async (url: string): Promise<string | null> => {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 8000);
try {
const res = await fetch(url, {
signal: ctrl.signal,
headers: { Accept: 'application/json' },
});
if (!res.ok) return null;
const data = await res.json();
const release = Array.isArray(data) ? data[0] : data;
return typeof release?.tag_name === 'string' ? release.tag_name : null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
};
try {
const u = new URL(repoUrl);
const [, owner, repo] = u.pathname.split('/');
if (!owner || !repo) return null;
const base = `${u.origin}/api/v1/repos/${owner}/${repo}/releases`;
return (
(await tagFrom(`${base}/latest`)) ??
(await tagFrom(`${base}?draft=false&limit=1`))
);
} catch {
return null;
}
};
const projects = await getCollection('projects');
return Promise.all(
projects.map(async (project) => ({
params: { slug: project.id },
props: { project, version: await fetchLatestRelease(project.data.links.codeberg) },
}))
);
}) satisfies GetStaticPaths;
const { project, version } = Astro.props;
const { name, status, released, platform, license, summary, tech, features, links } =
project.data;
const { Content } = await render(project);
---
<BaseLayout title={name} description={summary} type="article">
<a class="back-link" href="/work">← All work</a>
<header class="project-detail__head">
<div class="project-detail__status">
<span class={`project-card__status${released ? ' is-released' : ''}`}>
{status}{platform && <span class="sep"> · {platform}</span>}
</span>
{version && <span class="version-badge">{version}</span>}
</div>
<h1>{name}</h1>
<p class="lead">{summary}</p>
<div class="link-row">
{
links.fdroid && (
<a class="btn btn--filled" href={links.fdroid} rel="noopener">
<Icon name="simple-icons:fdroid" /> Get it on F-Droid
</a>
)
}
{
links.play && (
<a class="btn btn--tonal" href={links.play} rel="noopener">
<Icon name="simple-icons:googleplay" /> Get it on Google Play
</a>
)
}
{
links.codeberg && (
<a class="btn btn--outlined" href={links.codeberg} rel="noopener">
<Icon name="simple-icons:codeberg" /> Source on Codeberg
</a>
)
}
{
links.donate && (
<a class="btn btn--outlined" href={links.donate} rel="noopener">
<Icon name="simple-icons:kofi" class="icon-accent" /> Donate
</a>
)
}
{
links.translate && (
<a class="text-link" href={links.translate} rel="noopener">
<Icon name="simple-icons:weblate" /> Help translate
</a>
)
}
</div>
</header>
<div class="project-layout">
<div class="project-main">
<article class="prose project-intro"><Content /></article>
{
features.length > 0 && (
<section class="section" aria-label="Features">
<h2 class="subhead">Features</h2>
<ul class="feature-grid">
{features.map((f) => (
<li class="feature-card">
<h3>{f.title}</h3>
<p>{f.body}</p>
</li>
))}
</ul>
</section>
)
}
</div>
<aside class="project-aside">
<div class="info-card">
<h2 class="subhead">At a glance</h2>
<dl>
<dt>Status</dt>
<dd>{status}</dd>
{platform && (<><dt>Platform</dt><dd>{platform}</dd></>)}
{version && (<><dt>Latest release</dt><dd>{version}</dd></>)}
{license && (<><dt>License</dt><dd>{license}</dd></>)}
</dl>
{
tech.length > 0 && (
<ul class="chip-set tech-chips">
{tech.map((t) => (<li><span class="chip">{t}</span></li>))}
</ul>
)
}
</div>
</aside>
</div>
</BaseLayout>