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.
This commit is contained in:
2026-09-09 16:09:30 +02:00
parent 6311baccb7
commit ff3c0fa437
8 changed files with 122 additions and 52 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ export const LEGAL = {
// Social / external links shown in the footer. `icon` is an Iconify name. // Social / external links shown in the footer. `icon` is an Iconify name.
export const SOCIALS: { label: string; href: string; icon: string }[] = [ export const SOCIALS: { label: string; href: string; icon: string }[] = [
{ label: 'Gitea', href: 'https://gitea.jeanlucmakiola.de/makiolaj', icon: 'simple-icons:gitea' }, { label: 'Codeberg', href: 'https://codeberg.org/jlmakiola', icon: 'simple-icons:codeberg' },
{ label: 'Ko-fi', href: 'https://ko-fi.com/jeanlucmakiola', icon: 'simple-icons:kofi' }, { label: 'Ko-fi', href: 'https://ko-fi.com/jeanlucmakiola', icon: 'simple-icons:kofi' },
{ label: 'Email', href: `mailto:${LEGAL.email}`, icon: 'mdi:email-outline' }, { label: 'Email', href: `mailto:${LEGAL.email}`, icon: 'mdi:email-outline' },
]; ];
+3 -1
View File
@@ -39,8 +39,10 @@ const projects = defineCollection({
.default([]), .default([]),
links: z links: z
.object({ .object({
gitea: z.string().url().optional(), // Canonical source is Codeberg; the self-hosted Gitea is build infra.
codeberg: z.string().url().optional(),
fdroid: z.string().url().optional(), fdroid: z.string().url().optional(),
play: z.string().url().optional(),
donate: z.string().url().optional(), donate: z.string().url().optional(),
translate: z.string().url().optional(), translate: z.string().url().optional(),
}) })
+33 -19
View File
@@ -6,37 +6,51 @@ released: false
platform: Android 10+ platform: Android 10+
license: MIT license: MIT
summary: >- summary: >-
The task-list sibling to Calendula — a pure front-end over the OpenTasks The task-list sibling to Calendula. Keeps your tasks in its own store built
provider (CalDAV VTODOs), with no reinvented sync stack. around iCalendar VTODOs — or on top of a tasks provider you already sync —
with no account required.
tech: tech:
- Kotlin - Kotlin
- Jetpack Compose - Jetpack Compose
- Material 3 Expressive - Material 3 Expressive
- Room
features: features:
- title: Provider-native - title: Two places to keep tasks
body: >- body: >-
A pure front-end over the OpenTasks TaskContract provider. DAVx5, By default, Agendula's own database, designed around RFC 5545's VTODO —
SmoothSync, and DecSync sync your CalDAV VTODOs in; Agendula reads and no account, no permission, no other app needed. Or a provider you already
writes them — no own database. have (OpenTasks, tasks.org), left to whatever syncs it for you.
- title: Data layer done - title: Coexists, never replaces
body: >- body: >-
Provider resolution, live-updating reads, writes, smart-list filtering, Agendula's store is an ordinary app database, published to nothing, so
and a self-scheduled reminder engine are built and unit-tested. installing it never breaks OpenTasks — and if you already sync through a
- title: UI in progress provider, that keeps working exactly as it did.
- title: Nothing quietly lost
body: >- body: >-
The Material 3 Expressive screens are being built on top of the tested Recurring tasks are expanded per RFC 5545, and anything the schema doesn't
data layer, one at a time. model is round-tripped verbatim rather than dropped. Export to standard
.ics files is built in, because data you can't take with you isn't yours.
- title: Open standards only - title: Open standards only
body: >- body: >-
CalDAV, iCalendar, and DecSync are the lane. Proprietary task services are CalDAV, iCalendar, and DecSync are the lane; a CalDAV sync engine of
out of scope by design — they would mean owning a sync stack. Agendula's own is being built now. Google Tasks and Microsoft To Do are
out of scope by design.
links: links:
gitea: https://gitea.jeanlucmakiola.de/makiolaj/agendula codeberg: https://codeberg.org/jlmakiola/agendula
donate: https://ko-fi.com/jeanlucmakiola donate: https://ko-fi.com/jeanlucmakiola
translate: https://weblate.dev.jeanlucmakiola.de/engage/agendula/
--- ---
Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula
is a pure front-end over the **OpenTasks `TaskContract` provider**. The name carries **its own store** — a database designed against `VTODO`, the same shape
rhymes with its sibling on purpose: *Agendula* is *agenda* — Latin for "things DAVx5 (and SmoothSync, DecSync, …) syncs out of a CalDAV server. Using a tasks
to be done" — given Calendula's `-ula` ending. A Calendula flower head is a provider you already have is a choice rather than a requirement. The name rhymes
cluster of many small *florets*, so the two apps are florets of one bloom. with its sibling on purpose: *Agendula* is *agenda* — Latin for "things to be
done" — given Calendula's `-ula` ending. A Calendula flower head is a cluster of
many small *florets*, so the two apps are florets of one bloom.
**Where it stands:** the store, reminders, export, and the Material 3 Expressive
screens through task detail, editing and settings are built; German and
Brazilian Portuguese are the first community translations. Still ahead: a Glance
widget, the first F-Droid release, and Agendula's own CalDAV sync. Its
[privacy policy](/agendula/privacy) is already published.
+9 -2
View File
@@ -32,10 +32,12 @@ features:
- title: Private by default - title: Private by default
body: >- body: >-
Zero telemetry, zero analytics, no internet permission — your data never Zero telemetry, zero analytics, no internet permission — your data never
leaves the device. Dynamic color on Android 12+, German and English UI. leaves the device. Dynamic color on Android 12+, and fifteen community
translations alongside English.
links: links:
gitea: https://gitea.jeanlucmakiola.de/makiolaj/calendula codeberg: https://codeberg.org/jlmakiola/calendula
fdroid: https://f-droid.org/packages/de.jeanlucmakiola.calendula/ fdroid: https://f-droid.org/packages/de.jeanlucmakiola.calendula/
play: https://play.google.com/store/apps/details?id=de.jeanlucmakiola.calendula
donate: https://ko-fi.com/jeanlucmakiola donate: https://ko-fi.com/jeanlucmakiola
translate: https://weblate.dev.jeanlucmakiola.de/engage/calendula/ translate: https://weblate.dev.jeanlucmakiola.de/engage/calendula/
--- ---
@@ -46,3 +48,8 @@ on top of Android's `CalendarContract`: any calendar synced to your device
(CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply appears, and (CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply appears, and
everything you create or edit syncs back the same way. **No own database, no everything you create or edit syncs back the same way. **No own database, no
sync stack reinvented.** sync stack reinvented.**
Install it from F-Droid or Google Play — same app, same MIT source on Codeberg,
and still no internet permission in either build. There is also a
[privacy policy](/calendula/privacy) covering what the app does and doesn't do
with your data.
+16 -7
View File
@@ -10,22 +10,31 @@ summary: >-
app family — so each app draws from one bloom instead of reinventing it. app family — so each app draws from one bloom instead of reinventing it.
tech: tech:
- Kotlin - Kotlin
- Jetpack Compose
- Gradle composite build - Gradle composite build
features: features:
- title: Built from source - title: Built from source
body: >- body: >-
Embedded as a git submodule and wired in with a Gradle composite build — Embedded as a git submodule and wired in with a Gradle composite build —
no published artifacts, which keeps every app reproducible for F-Droid. no published artifacts, which keeps every app reproducible for F-Droid.
- title: core-time - title: Identity and components
body: >- body: >-
Pure-Kotlin date/time helpers: local-day windows for smart-list logic and The M3 Expressive theme factory, navigation motion and predictive-back
locale/zone-aware display formatting. No Android, no dependencies. peek, plus the shared Compose vocabulary each screen is written out of —
- title: Growing grouped rows, inline fields, option cards, full-screen pickers,
collapsing scaffolds.
- title: Plumbing, not looks
body: >- body: >-
Identity/theme, components, screen recipes, and provider / prefs / core-time (local-day windows, locale-aware formatting), core-reminders
reminders / crash plumbing land as they're extracted from the apps. (lead-time model and codec), core-locale (per-app language), core-crash
(on-device capture and issue hand-off) — each app layers its own storage
and strings on top.
- title: Mechanics shared, look per-app
body: >-
The principle the kit is held to: a component moves here once two apps
need the same behaviour, while palette and personality stay with the app.
links: links:
gitea: https://gitea.jeanlucmakiola.de/makiolaj/floret-kit codeberg: https://codeberg.org/jlmakiola/floret-kit
donate: https://ko-fi.com/jeanlucmakiola donate: https://ko-fi.com/jeanlucmakiola
--- ---
+17 -6
View File
@@ -46,23 +46,34 @@ import { LEGAL } from '../consts';
<ul> <ul>
<li><strong>Kotlin</strong> and <strong>Jetpack Compose</strong>, designed in <strong>Material 3 Expressive</strong>.</li> <li><strong>Kotlin</strong> and <strong>Jetpack Compose</strong>, designed in <strong>Material 3 Expressive</strong>.</li>
<li> <li>
No reinvented sync stack — each app is a front-end over a platform Open standards over proprietary services — CalDAV, iCalendar, DecSync.
provider (<code>CalendarContract</code>, the OpenTasks{' '} Calendula is a pure front-end over Android's <code>CalendarContract</code>;
<code>TaskContract</code>) and open standards like CalDAV, iCalendar, and Agendula keeps its own <code>VTODO</code> store, or rides a tasks
DecSync. provider you already sync.
</li> </li>
<li> <li>
A shared design system, <a href="/work/floret-kit">floret-kit</a>, wired A shared design system, <a href="/work/floret-kit">floret-kit</a>, wired
in as a git submodule via a Gradle composite build — no published in as a git submodule via a Gradle composite build — no published
artifacts, so every app stays reproducible. artifacts, so every app stays reproducible.
</li> </li>
<li>Released on <a href="https://f-droid.org" rel="noopener">F-Droid</a>, MIT-licensed, zero telemetry.</li> <li>
Released on <a href="https://f-droid.org" rel="noopener">F-Droid</a> and
Google Play, MIT-licensed, zero telemetry.
</li>
</ul> </ul>
<h2>Self-hosted infrastructure</h2> <h2>Self-hosted infrastructure</h2>
<p>Code, translations, builds, and this site all run on infrastructure I host myself.</p> <p>Code, translations, builds, and this site all run on infrastructure I host myself.</p>
<ul> <ul>
<li><a href="https://gitea.jeanlucmakiola.de/makiolaj" rel="noopener">Gitea</a> for source hosting and as the home for smaller experiments.</li> <li>
<a href="https://codeberg.org/jlmakiola" rel="noopener">Codeberg</a> is
where the apps live — source, issues, releases.
</li>
<li>
Self-hosted <a href="https://gitea.jeanlucmakiola.de/makiolaj" rel="noopener">Gitea</a>{' '}
runs the build side of that — signing, the F-Droid repo, release
pipelines — and is home to smaller experiments.
</li>
<li><a href="https://weblate.org" rel="noopener">Weblate</a> for community translations of the apps.</li> <li><a href="https://weblate.org" rel="noopener">Weblate</a> for community translations of the apps.</li>
<li><a href="https://coolify.io" rel="noopener">Coolify</a> to build and deploy this site.</li> <li><a href="https://coolify.io" rel="noopener">Coolify</a> to build and deploy this site.</li>
<li>Umami for the privacy-respecting analytics above.</li> <li>Umami for the privacy-respecting analytics above.</li>
+38 -16
View File
@@ -5,26 +5,41 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import type { GetStaticPaths } from 'astro'; import type { GetStaticPaths } from 'astro';
export const getStaticPaths = (async () => { export const getStaticPaths = (async () => {
// Build-time: resolve the latest release tag from the project's Gitea repo. // 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. // Never throws — returns null on 404/offline so the build can't break.
// (Defined inside getStaticPaths: Astro extracts this fn into its own scope.) // (Defined inside getStaticPaths: Astro extracts this fn into its own scope.)
const fetchLatestRelease = async (repoUrl?: string): Promise<string | null> => { const fetchLatestRelease = async (repoUrl?: string): Promise<string | null> => {
if (!repoUrl) return 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 { try {
const u = new URL(repoUrl); const u = new URL(repoUrl);
const [, owner, repo] = u.pathname.split('/'); const [, owner, repo] = u.pathname.split('/');
if (!owner || !repo) return null; if (!owner || !repo) return null;
const api = `${u.origin}/api/v1/repos/${owner}/${repo}/releases/latest`; const base = `${u.origin}/api/v1/repos/${owner}/${repo}/releases`;
const ctrl = new AbortController(); return (
const timer = setTimeout(() => ctrl.abort(), 8000); (await tagFrom(`${base}/latest`)) ??
const res = await fetch(api, { (await tagFrom(`${base}?draft=false&limit=1`))
signal: ctrl.signal, );
headers: { Accept: 'application/json' },
});
clearTimeout(timer);
if (!res.ok) return null;
const data = await res.json();
return typeof data.tag_name === 'string' ? data.tag_name : null;
} catch { } catch {
return null; return null;
} }
@@ -34,7 +49,7 @@ export const getStaticPaths = (async () => {
return Promise.all( return Promise.all(
projects.map(async (project) => ({ projects.map(async (project) => ({
params: { slug: project.id }, params: { slug: project.id },
props: { project, version: await fetchLatestRelease(project.data.links.gitea) }, props: { project, version: await fetchLatestRelease(project.data.links.codeberg) },
})) }))
); );
}) satisfies GetStaticPaths; }) satisfies GetStaticPaths;
@@ -67,9 +82,16 @@ const { Content } = await render(project);
) )
} }
{ {
links.gitea && ( links.play && (
<a class="btn btn--outlined" href={links.gitea} rel="noopener"> <a class="btn btn--tonal" href={links.play} rel="noopener">
<Icon name="simple-icons:gitea" /> Source on Gitea <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> </a>
) )
} }
+5
View File
@@ -212,6 +212,11 @@ a:hover { text-decoration-color: currentColor; }
.btn:active { transform: scale(0.97); } .btn:active { transform: scale(0.97); }
.btn--filled { background: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); } .btn--filled { background: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary); }
.btn--filled:hover { box-shadow: var(--md-sys-elevation-2); } .btn--filled:hover { box-shadow: var(--md-sys-elevation-2); }
.btn--tonal {
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
.btn--tonal:hover { box-shadow: var(--md-sys-elevation-1); }
.btn--outlined { .btn--outlined {
background: transparent; background: transparent;
color: var(--md-sys-color-on-surface); color: var(--md-sys-color-on-surface);