commit 074199c6566aba7d64c52863d9c407e5b2521448 Author: Jean-Luc Makiola Date: Sun Jun 28 13:39:08 2026 +0200 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) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1f82636 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.astro +.git +.env +.env.production +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8929305 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# build output +dist/ +# generated types +.astro/ +# deps +node_modules/ +# env +.env +.env.production +# os / editor +.DS_Store +*.log +# local screenshots +.shots/ diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..68b3ddf --- /dev/null +++ b/Caddyfile @@ -0,0 +1,16 @@ +# Caddy serves the static build inside the container on :80. +# Coolify's proxy terminates TLS and handles the public domain. +:80 { + root * /srv + encode zstd gzip + file_server + + # Astro builds "/foo" -> "/foo/index.html"; try clean URLs first. + try_files {path} {path}/ {path}.html /404.html + + # Long-cache fingerprinted assets, revalidate HTML. + @assets path /_astro/* + header @assets Cache-Control "public, max-age=31536000, immutable" + @html path *.html / + header @html Cache-Control "public, max-age=0, must-revalidate" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cf9a36d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# --- Build stage: produce static dist/ --- +FROM node:22-alpine AS build +WORKDIR /app + +# Install deps against the lockfile for reproducible builds. +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . +# PUBLIC_* env vars are inlined at build time. In Coolify, set them as +# build-time variables so analytics is baked into the static output. +RUN npm run build + +# --- Runtime stage: serve dist/ with Caddy --- +FROM caddy:2-alpine AS runtime +COPY Caddyfile /etc/caddy/Caddyfile +COPY --from=build /app/dist /srv +EXPOSE 80 diff --git a/PLANNING.md b/PLANNING.md new file mode 100644 index 0000000..3b2824a --- /dev/null +++ b/PLANNING.md @@ -0,0 +1,81 @@ +# jeanlucmakiola.de — personal website + +Status: **discussion / not started** · Started 2026-06-28 + +## Goal +A simple personal website (with room to grow into a blog), self-hosted on +Coolify, privacy-respecting analytics, solid SEO, and an AI-driven authoring +flow (content written as files by an agent, reviewed via git diff, auto-deployed +on push). + +## Hard requirements +- Self-hosted deploy on **Coolify** (own infra) +- Privacy-respecting **analytics** (Umami preferred) +- **SEO**: sitemap, RSS, canonical URLs, Open Graph, fast/static +- **AI-driven flow**: agent can author/edit content via CLI/files (and/or API) +- Keep it simple; nothing crazy. Blog is a likely-but-later addition. + +## Recommended stack (proposed) +| Concern | Choice | Why | +|----------------|------------------------------------------|-----| +| Framework | **Astro** (static output) | Content-first, zero-JS default, content collections, great SEO | +| Content | **Markdown/MDX in git** (`src/content/`) | AI writes files; git is the API; no lock-in | +| Analytics | **Umami**, self-hosted on Coolify | Privacy-respecting, cookieless, one `} diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro new file mode 100644 index 0000000..461abab --- /dev/null +++ b/src/components/BaseHead.astro @@ -0,0 +1,59 @@ +--- +// SEO + document head. Renders title, meta description, canonical URL, +// Open Graph / Twitter tags, RSS autodiscovery, and the favicon. +import { SITE } from '../consts'; + +interface Props { + title: string; + description?: string; + /** Optional absolute or root-relative OG image path. */ + image?: string; + /** 'website' for pages, 'article' for blog posts. */ + type?: 'website' | 'article'; +} + +const { + title, + description = SITE.description, + image = '/og-default.png', + type = 'website', +} = Astro.props; + +// Astro.site comes from astro.config `site`. Canonical = site + current path. +const canonicalURL = new URL(Astro.url.pathname, Astro.site); +const imageURL = new URL(image, Astro.site); +const fullTitle = title === SITE.title ? title : `${title} · ${SITE.title}`; +--- + + + + + +{fullTitle} + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Footer.astro b/src/components/Footer.astro new file mode 100644 index 0000000..9a7b40e --- /dev/null +++ b/src/components/Footer.astro @@ -0,0 +1,23 @@ +--- +import { Icon } from 'astro-icon/components'; +import { SITE, SOCIALS } from '../consts'; +const year = new Date().getFullYear(); +--- + +
+ +
diff --git a/src/components/FormattedDate.astro b/src/components/FormattedDate.astro new file mode 100644 index 0000000..17065dc --- /dev/null +++ b/src/components/FormattedDate.astro @@ -0,0 +1,16 @@ +--- +import { SITE } from '../consts'; +interface Props { + date: Date; +} +const { date } = Astro.props; +--- + diff --git a/src/components/Header.astro b/src/components/Header.astro new file mode 100644 index 0000000..0f1ad76 --- /dev/null +++ b/src/components/Header.astro @@ -0,0 +1,30 @@ +--- +import { SITE } from '../consts'; + +const path = Astro.url.pathname; +const isActive = (href: string) => + href === '/' ? path === '/' : path.startsWith(href); + +const nav = [ + { label: 'Home', href: '/' }, + { label: 'Work', href: '/work' }, + { label: 'Blog', href: '/blog' }, +]; +--- + + diff --git a/src/consts.ts b/src/consts.ts new file mode 100644 index 0000000..a40e395 --- /dev/null +++ b/src/consts.ts @@ -0,0 +1,16 @@ +// Central site config. Imported by astro.config.mjs, layouts, RSS, and SEO head. +export const SITE = { + url: 'https://jeanlucmakiola.de', + title: 'Jean-Luc Makiola', + description: 'Personal site of Jean-Luc Makiola — projects, writing, and notes.', + author: 'Jean-Luc Makiola', + lang: 'en', + locale: 'en_US', +} as const; + +// Social / external links shown in the footer. `icon` is an Iconify name. +export const SOCIALS: { label: string; href: string; icon: string }[] = [ + { label: 'Gitea', href: 'https://gitea.jeanlucmakiola.de/makiolaj', icon: 'simple-icons:gitea' }, + { label: 'Ko-fi', href: 'https://ko-fi.com/jeanlucmakiola', icon: 'simple-icons:kofi' }, + { label: 'Email', href: 'mailto:mail@jeanlucmakiola.de', icon: 'mdi:email-outline' }, +]; diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..f9c398d --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,51 @@ +import { defineCollection, z } from 'astro:content'; +import { glob } from 'astro/loaders'; + +// Blog posts: Markdown/MDX files in src/content/blog/. +// Frontmatter is validated against this schema at build time. +const blog = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }), + schema: z.object({ + title: z.string(), + description: z.string(), + // Accepts an ISO date string in frontmatter, parsed to a Date. + pubDate: z.coerce.date(), + updatedDate: z.coerce.date().optional(), + tags: z.array(z.string()).default([]), + // Set true to keep a post out of listings/feeds while you work on it. + draft: z.boolean().default(false), + }), +}); + +// Projects / apps: one Markdown file per app in src/content/projects/. +// Frontmatter holds the structured bits (status, links); the body is the +// long-form overview + features rendered on the per-app page. +const projects = defineCollection({ + loader: glob({ pattern: '**/*.md', base: './src/content/projects' }), + schema: z.object({ + name: z.string(), + // lower = earlier in listings + order: z.number().default(99), + status: z.string(), + released: z.boolean().default(false), + platform: z.string().optional(), + license: z.string().optional(), + summary: z.string(), + // tech stack chips shown in the "At a glance" panel + tech: z.array(z.string()).default([]), + // structured feature cards (rendered as a grid, not prose) + features: z + .array(z.object({ title: z.string(), body: z.string() })) + .default([]), + links: z + .object({ + gitea: z.string().url().optional(), + fdroid: z.string().url().optional(), + donate: z.string().url().optional(), + translate: z.string().url().optional(), + }) + .default({}), + }), +}); + +export const collections = { blog, projects }; diff --git a/src/content/blog/hello-world.md b/src/content/blog/hello-world.md new file mode 100644 index 0000000..1d1eda7 --- /dev/null +++ b/src/content/blog/hello-world.md @@ -0,0 +1,14 @@ +--- +title: Hello, world +description: First post — what this site is and how it's built. +pubDate: 2026-06-28 +tags: [meta] +draft: false +--- + +This is the first post on my new site. It's built with [Astro](https://astro.build), +deployed on a self-hosted [Coolify](https://coolify.io), and kept honest with +privacy-respecting analytics. + +Posts live as Markdown files in the repository — so writing here is just editing +text and pushing a commit. More to come. diff --git a/src/content/projects/agendula.md b/src/content/projects/agendula.md new file mode 100644 index 0000000..21ba1ec --- /dev/null +++ b/src/content/projects/agendula.md @@ -0,0 +1,42 @@ +--- +name: Agendula +order: 2 +status: In development +released: false +platform: Android 10+ +license: MIT +summary: >- + The task-list sibling to Calendula — a pure front-end over the OpenTasks + provider (CalDAV VTODOs), with no reinvented sync stack. +tech: + - Kotlin + - Jetpack Compose + - Material 3 Expressive +features: + - title: Provider-native + body: >- + A pure front-end over the OpenTasks TaskContract provider. DAVx5, + SmoothSync, and DecSync sync your CalDAV VTODOs in; Agendula reads and + writes them — no own database. + - title: Data layer done + body: >- + Provider resolution, live-updating reads, writes, smart-list filtering, + and a self-scheduled reminder engine are built and unit-tested. + - title: UI in progress + body: >- + The Material 3 Expressive screens are being built on top of the tested + data layer, one at a time. + - title: Open standards only + body: >- + CalDAV, iCalendar, and DecSync are the lane. Proprietary task services are + out of scope by design — they would mean owning a sync stack. +links: + gitea: https://gitea.jeanlucmakiola.de/makiolaj/agendula + donate: https://ko-fi.com/jeanlucmakiola +--- + +Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula +is a pure front-end over the **OpenTasks `TaskContract` provider**. The name +rhymes 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. diff --git a/src/content/projects/calendula.md b/src/content/projects/calendula.md new file mode 100644 index 0000000..975fd24 --- /dev/null +++ b/src/content/projects/calendula.md @@ -0,0 +1,48 @@ +--- +name: Calendula +order: 1 +status: Released +released: true +platform: Android 10+ +license: MIT +summary: >- + A Material 3 Expressive calendar for Android. Reads, writes, and reminds on + top of the system calendar — any CalDAV account just appears — with zero + network access of its own. +tech: + - Kotlin + - Jetpack Compose + - Material 3 Expressive +features: + - title: Calendar views + body: >- + Month, week, and day views with a one-tap switcher. Full event detail — + attendees and responses, reminders, humanized recurrence, availability, + visibility, and foreign time zones. + - title: Safe editing + body: >- + Scoped writes for recurring events (only this, this-and-following, or the + whole series), a recurrence picker for presets and custom rules, and + conflict-safe saves that ask before overwriting. + - title: Built-in reminders + body: >- + Delivered by Calendula itself as notifications — essential when it's your + only calendar app, since Android delegates reminder delivery to calendar + apps. Tap a reminder to land on the event. + - title: Private by default + body: >- + Zero telemetry, zero analytics, no internet permission — your data never + leaves the device. Dynamic color on Android 12+, German and English UI. +links: + gitea: https://gitea.jeanlucmakiola.de/makiolaj/calendula + fdroid: https://f-droid.org/packages/de.jeanlucmakiola.calendula/ + donate: https://ko-fi.com/jeanlucmakiola + translate: https://weblate.dev.jeanlucmakiola.de/engage/calendula/ +--- + +Calendula is named after the flower whose name — like the word *calendar* — +comes from the Latin *kalendae*, the first day of the month. It lives entirely +on top of Android's `CalendarContract`: any calendar synced to your device +(CalDAV via DAVx5, Google, local, WebCal subscriptions, …) simply appears, and +everything you create or edit syncs back the same way. **No own database, no +sync stack reinvented.** diff --git a/src/content/projects/floret-kit.md b/src/content/projects/floret-kit.md new file mode 100644 index 0000000..545ed07 --- /dev/null +++ b/src/content/projects/floret-kit.md @@ -0,0 +1,37 @@ +--- +name: floret-kit +order: 3 +status: Design system +released: false +platform: Android library +license: MIT +summary: >- + The shared Material 3 Expressive design system and plumbing behind the Floret + app family — so each app draws from one bloom instead of reinventing it. +tech: + - Kotlin + - Gradle composite build +features: + - title: Built from source + body: >- + Embedded as a git submodule and wired in with a Gradle composite build — + no published artifacts, which keeps every app reproducible for F-Droid. + - title: core-time + body: >- + Pure-Kotlin date/time helpers: local-day windows for smart-list logic and + locale/zone-aware display formatting. No Android, no dependencies. + - title: Growing + body: >- + Identity/theme, components, screen recipes, and provider / prefs / + reminders / crash plumbing land as they're extracted from the apps. +links: + gitea: https://gitea.jeanlucmakiola.de/makiolaj/floret-kit + donate: https://ko-fi.com/jeanlucmakiola +--- + +"Floret" is the family's design language: a Calendula flower head is a cluster +of many small *florets*, and each app is one of them. floret-kit is the bloom +they share — so a new app doesn't reinvent a settings screen, an edit form, or a +theme; it draws from here. Gradle's dependency substitution maps +`de.jeanlucmakiola.floret:` to local source, so editing the kit updates +every app immediately. diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..3d32568 --- /dev/null +++ b/src/layouts/BaseLayout.astro @@ -0,0 +1,38 @@ +--- +import BaseHead from '../components/BaseHead.astro'; +import Analytics from '../components/Analytics.astro'; +import Header from '../components/Header.astro'; +import Footer from '../components/Footer.astro'; +import { SITE } from '../consts'; + +// Self-hosted brand fonts (privacy-respecting — no Google CDN). +import '@fontsource-variable/inter'; +import '@fontsource-variable/jetbrains-mono'; +import '../styles/global.css'; + +interface Props { + title: string; + description?: string; + image?: string; + type?: 'website' | 'article'; + /** 'narrow' caps width for long-form reading (blog, posts, tags). */ + width?: 'wide' | 'narrow'; +} + +const { title, description, image, type, width = 'wide' } = Astro.props; +--- + + + + + + + + +
+
+ +
+