Initial site: Astro + M3 Expressive, projects, blog

Personal site & blog for jeanlucmakiola.de.

- Astro 7 static output; self-hosted Inter + JetBrains Mono (Fontsource)
- Material 3 Expressive design tokens (red accent over neutral surfaces)
- Home (hero + about + selected work + latest writing)
- /work index + per-app pages (Calendula, Agendula, floret-kit) with
  build-time Gitea release tags, brand-icon link rows, features grid,
  and an at-a-glance facts panel
- Blog via Markdown content collection; RSS, sitemap, robots, OG/SEO
- Privacy-respecting Umami analytics, env-gated (off in dev)
- Dockerfile (build -> Caddy) + Caddyfile for Coolify deploy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Luc Makiola
2026-06-28 13:39:08 +02:00
commit 074199c656
35 changed files with 6954 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
---
import { getCollection, render } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro';
import type { GetStaticPaths } from 'astro';
export const getStaticPaths = (async () => {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}) satisfies GetStaticPaths;
const { post } = Astro.props;
const { Content } = await render(post);
---
<BlogPost post={post.data}>
<Content />
</BlogPost>

View File

@@ -0,0 +1,27 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import FormattedDate from '../../components/FormattedDate.astro';
import { getCollection } from 'astro:content';
const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
---
<BaseLayout title="Blog" description="Writing and notes by Jean-Luc Makiola." width="narrow">
<h1 class="page-title">Blog</h1>
{posts.length === 0 && <p>No posts yet — check back soon.</p>}
<ul class="post-list">
{
posts.map((post) => (
<li>
<a class="post-card" href={`/blog/${post.id}/`}>
<h3>{post.data.title}</h3>
<FormattedDate date={post.data.pubDate} />
{post.data.description && <p class="excerpt">{post.data.description}</p>}
</a>
</li>
))
}
</ul>
</BaseLayout>

110
src/pages/index.astro Normal file
View File

@@ -0,0 +1,110 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { Icon } from 'astro-icon/components';
import { getCollection } from 'astro:content';
import { SITE } from '../consts';
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
.slice(0, 4);
const fmt = (d: Date) =>
d.toLocaleDateString(SITE.lang, { year: 'numeric', month: 'short' });
const GITEA = 'https://gitea.jeanlucmakiola.de/makiolaj';
// Selected work — pulled from the projects collection (single source of truth;
// per-app pages live at /work/<slug>). Older experiments live on Gitea.
const projects = (await getCollection('projects')).sort(
(a, b) => a.data.order - b.data.order
);
---
<BaseLayout title={SITE.title}>
<!-- Intro: hero + about side by side on wide screens -->
<section class="intro" aria-label="Intro">
<div class="hero intro__lead">
<span class="hero__eyebrow">Developer &amp; designer</span>
<h1>Jean-Luc Makiola<span class="dot">.</span></h1>
<p class="lead">
I build design-led Android apps with Material 3 Expressive on top of
open, self-hostable standards. This is my corner of the web — projects,
writing, and notes.
</p>
<div class="hero__actions">
<a class="btn btn--filled" href="#work">View work</a>
<a class="text-link" href="/blog">Read the blog <span class="dot">→</span></a>
<a class="text-link" href="mailto:mail@jeanlucmakiola.de">
<Icon name="mdi:email-outline" /> Get in touch
</a>
</div>
</div>
<div class="about intro__about" id="about">
<p>
I'd rather put a thoughtful interface on top of an open protocol than
reinvent a sync stack. My apps read and write through the platform's own
providers, so your data stays yours — and stays portable.
</p>
<p class="muted">
They form the <strong>Floret</strong> family: small, focused tools that
share one expressive design language. Everything around them — code,
translations, builds — runs on infrastructure I host myself.
</p>
</div>
</section>
<!-- Selected work -->
<section class="section" id="work">
<div class="section-head">
<h2>Selected work</h2>
<a class="btn btn--text" href="/work">All work →</a>
</div>
<ul class="project-grid">
{
projects.map((p) => (
<li>
<a class="project-card" href={`/work/${p.id}/`}>
<span class={`project-card__status${p.data.released ? ' is-released' : ''}`}>
{p.data.status}
</span>
<span class="project-card__title">
{p.data.name}
<span class="arrow" aria-hidden="true">→</span>
</span>
<p>{p.data.summary}</p>
</a>
</li>
))
}
</ul>
<p class="aside">
Plus a trail of smaller experiments and utilities — most unfinished by
design — over on <a href={GITEA} rel="noopener">my Gitea</a>.
</p>
</section>
<!-- Latest writing -->
{
posts.length > 0 && (
<section class="section">
<div class="section-head">
<h2>Latest writing</h2>
<a class="btn btn--text" href="/blog">All posts →</a>
</div>
<ul class="writing-list">
{posts.map((post) => (
<li>
<a class="writing-row" href={`/blog/${post.id}/`}>
<span class="writing-row__title">{post.data.title}</span>
<time datetime={post.data.pubDate.toISOString()}>
{fmt(post.data.pubDate)}
</time>
</a>
</li>
))}
</ul>
</section>
)
}
</BaseLayout>

21
src/pages/rss.xml.js Normal file
View File

@@ -0,0 +1,21 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import { SITE } from '../consts';
export async function GET(context) {
const posts = (await getCollection('blog', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
return rss({
title: SITE.title,
description: SITE.description,
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.pubDate,
link: `/blog/${post.id}/`,
})),
});
}

View File

@@ -0,0 +1,41 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import FormattedDate from '../../components/FormattedDate.astro';
import { getCollection } from 'astro:content';
import type { GetStaticPaths } from 'astro';
export const getStaticPaths = (async () => {
const posts = await getCollection('blog', ({ data }) => !data.draft);
const tags = [...new Set(posts.flatMap((p) => p.data.tags))];
return tags.map((tag) => ({
params: { tag },
props: {
tag,
posts: posts
.filter((p) => p.data.tags.includes(tag))
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()),
},
}));
}) satisfies GetStaticPaths;
const { tag, posts } = Astro.props;
---
<BaseLayout title={`#${tag}`} description={`Posts tagged ${tag}.`} width="narrow">
<h1 class="page-title">#{tag}</h1>
<ul class="post-list">
{
posts.map((post) => (
<li>
<a class="post-card" href={`/blog/${post.id}/`}>
<h3>{post.data.title}</h3>
<FormattedDate date={post.data.pubDate} />
</a>
</li>
))
}
</ul>
<p style="margin-top: var(--md-sys-spacing-8)">
<a class="btn btn--text" href="/blog">← All posts</a>
</p>
</BaseLayout>

134
src/pages/work/[slug].astro Normal file
View File

@@ -0,0 +1,134 @@
---
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 Gitea repo.
// 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;
try {
const u = new URL(repoUrl);
const [, owner, repo] = u.pathname.split('/');
if (!owner || !repo) return null;
const api = `${u.origin}/api/v1/repos/${owner}/${repo}/releases/latest`;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 8000);
const res = await fetch(api, {
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 {
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.gitea) },
}))
);
}) 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.gitea && (
<a class="btn btn--outlined" href={links.gitea} rel="noopener">
<Icon name="simple-icons:gitea" /> Source on Gitea
</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>

View File

@@ -0,0 +1,39 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getCollection } from 'astro:content';
const GITEA = 'https://gitea.jeanlucmakiola.de/makiolaj';
const projects = (await getCollection('projects')).sort(
(a, b) => a.data.order - b.data.order
);
---
<BaseLayout
title="Work"
description="Projects by Jean-Luc Makiola — the Floret family of Material 3 Expressive Android apps."
>
<h1 class="page-title">Work</h1>
<p class="lead" style="margin-top: calc(-1 * var(--md-sys-spacing-2)); max-width: 54ch">
Intentional, maintained projects — the Floret family. Smaller experiments
live on <a href={GITEA} rel="noopener">my Gitea</a>.
</p>
<ul class="project-grid" style="margin-top: var(--md-sys-spacing-8)">
{
projects.map((p) => (
<li>
<a class="project-card" href={`/work/${p.id}/`}>
<span class={`project-card__status${p.data.released ? ' is-released' : ''}`}>
{p.data.status}
</span>
<span class="project-card__title">
{p.data.name}
<span class="arrow" aria-hidden="true">→</span>
</span>
<p>{p.data.summary}</p>
</a>
</li>
))
}
</ul>
</BaseLayout>