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.
157 lines
5.0 KiB
Plaintext
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>
|