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:
20
src/pages/blog/[...slug].astro
Normal file
20
src/pages/blog/[...slug].astro
Normal 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>
|
||||
27
src/pages/blog/index.astro
Normal file
27
src/pages/blog/index.astro
Normal 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
110
src/pages/index.astro
Normal 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 & 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
21
src/pages/rss.xml.js
Normal 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}/`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
41
src/pages/tags/[tag].astro
Normal file
41
src/pages/tags/[tag].astro
Normal 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
134
src/pages/work/[slug].astro
Normal 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>
|
||||
39
src/pages/work/index.astro
Normal file
39
src/pages/work/index.astro
Normal 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>
|
||||
Reference in New Issue
Block a user