website for the dezentrale erkelenz
  • Rust 53.4%
  • HTML 33.6%
  • CSS 11.9%
  • JavaScript 0.6%
  • Dockerfile 0.5%
Find a file
Sandro Bohmer 389de9da41
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m23s
fix(ui): span topbar separator across content width
2026-11-09 12:10:15 +01:00
.forgejo/workflows ci: update IMAGE_NAME to c3e3/dezentrale-website 2026-11-08 12:28:50 +01:00
.idea feat: redesign site and manage blog/documents via external git repo 2026-11-07 17:28:30 +01:00
flyer-assets updated flyer.html 2026-10-07 20:01:47 +02:00
messages feat(ui): add mobile sidebar drawer and lengthen topbar rule 2026-11-09 11:55:35 +01:00
public fix(ui): span topbar separator across content width 2026-11-09 12:10:15 +01:00
src fix(ui): unify page decor and remove privacy notice background 2026-11-09 11:39:32 +01:00
templates fix(ui): span topbar separator across content width 2026-11-09 12:10:15 +01:00
.dockerignore feat: replace Next.js site with Rust/Axum implementation 2026-11-09 10:51:58 +01:00
.env.example feat: replace Next.js site with Rust/Axum implementation 2026-11-09 10:51:58 +01:00
.gitignore feat: replace Next.js site with Rust/Axum implementation 2026-11-09 10:51:58 +01:00
Cargo.lock feat: replace Next.js site with Rust/Axum implementation 2026-11-09 10:51:58 +01:00
Cargo.toml feat: replace Next.js site with Rust/Axum implementation 2026-11-09 10:51:58 +01:00
docker-compose.yml feat: replace Next.js site with Rust/Axum implementation 2026-11-09 10:51:58 +01:00
Dockerfile feat: replace Next.js site with Rust/Axum implementation 2026-11-09 10:51:58 +01:00
flyer.html fix: waves are now thicker so that they aline 2026-10-08 09:20:11 +02:00
README.md feat: replace Next.js site with Rust/Axum implementation 2026-11-09 10:51:58 +01:00

Dezentrale Erkelenz – Rust/Axum Website

Reimplementation of the website for the c3e3 – Dezentrale Erkelenz hack- and makerspace, built with Rust and Axum. The site is fully server-rendered, bilingual (German/English), and works without a JavaScript framework.

Overview

  • Rust + Axum instead of Next.js, shipped as a single binary.
  • Language-independent URLs without a locale prefix, e.g. /about/, /blog/willkommen/.
  • Left-hand grouped navigation (Information, Association, Legal, Meta).
  • Breadcrumbs with a home icon at the top: ⌂ / blog / willkommen.
  • Automatic dark/light mode via prefers-color-scheme.
  • Minimal JavaScript: only the Leaflet map on the location page. The calendar, navigation, language switch and all other pages work without JS.
  • Blog, documents, ICS calendar, SpaceAPI, RSS, sitemap, robots.txt, legal notice, privacy, contact and support.
  • Content comes from an external Git repository (CONTENT_REPO_URL) or from a local checkout.

Requirements

  • Rust 1.88 or newer (edition 2024). Tested with cargo 1.99.1.
  • Optional: git for automatically syncing the content repository.
  • Optional: Docker and Docker Compose.

Running Locally

cd /home/mppny/Dev/repo/dezentrale-website-rust
cargo run

By default the server runs at http://127.0.0.1:3000.

On first start the application looks for content in this order:

  1. CONTENT_DIR, if set.
  2. ../dezentrale-content, if present and CONTENT_REPO_URL is not set.
  3. ./content.

For local development the content repository usually lives at ../dezentrale-content. Example content:

../dezentrale-content/
├── blog/
│   └── willkommen/
│       ├── content.de.md
│       ├── content.en.md
│       └── cover.jpg
└── documents/
    ├── Satzung.pdf
    └── Mitgliedsantrag.pdf

Configuration

All variables can be set through the environment or .env / .env.local. Template: .env.example.

Variable Default Description
HOST 127.0.0.1 Bind address. Use 0.0.0.0 inside Docker.
PORT 3000 HTTP port.
SITE_URL https://dezentrale-erkelenz.de Public base URL for canonical links, RSS and sitemap.
CONTENT_REPO_URL – Git repository containing blog/ and documents/. Empty = local checkout.
CONTENT_REPO_BRANCH HEAD Branch/tag for the content sync.
CONTENT_SYNC_INTERVAL_SECONDS 60 Background sync interval (min. 5 s).
CONTENT_DIR see above Forces a specific content directory.
PUBLIC_DIR ./public Directory for static files.
SPACE_STATUS_FILE ./content/space/status.json File for the SpaceAPI status.
SPACEAPI_SECRET_TOKEN – Token for write requests to the SpaceAPI.
NEXTCLOUD_CALENDAR_ICS_URL – Public ICS export URL for the calendar.
CALENDAR_CACHE_SECONDS 120 In-memory cache for the ICS feed.

Routes

Path Description
/ Home page with space status, page links and the next meetings.
/location/ Directions and OpenStreetMap map.
/about/ About the space.
/calendar/ All events, grouped by month and day.
/blog/ Blog overview.
/blog/{slug}/ Individual blog post.
/documents/ Documents from the content repository.
/donate/ Support us.
/contact/ Contact.
/imprint/ Legal notice.
/privacy/ Privacy policy.
/rss.xml RSS feed (language via cookie/query).
/de/rss.xml, /en/rss.xml Fixed RSS feeds per language (legacy URLs).
/sitemap.xml Sitemap.
/robots.txt Robots file.
/api/space SpaceAPI 0.13 (GET, PUT, OPTIONS).
/api/documents/{filename} Document download.
/api/blog-posts/{slug}/images/{image} Images from blog posts.
/lang/de/, /lang/en/ Set the language and redirect back (?next=/path/).

Legacy locale-prefixed URLs such as /de/about/ or /en/blog/ permanently redirect to the unprefixed variant and set the language in the process.

Language

The language is determined in this order:

  1. ?lang=de / ?lang=en (stored as a cookie),
  2. lang cookie,
  3. Accept-Language,
  4. default: German.

Content

Blog

A post is a directory under blog/. Supported layouts:

  • content.de.md + content.en.md (bilingual),
  • content.md (single language),
  • index.de.md / index.en.md,
  • a single Markdown file blog/{slug}.md.

Frontmatter (YAML):

---
title: Welcome to our site!
excerpt: A quick overview.
publishedAt: '2026-11-07T16:00:00.000Z'
readingTime: 1 min read
category: Space
locale: de/en
status: public
coverImage: ./cover.jpg
coverImageAlt: Dezentrale Erkelenz
---

status can be public or draft. Relative image paths such as ./cover.jpg are automatically served through the image endpoint. Markdown is rendered with pulldown-cmark.

Documents

All files under documents/ are listed, except _metadata.json, metadata.json and readme.md. Optionally, _metadata.json controls display names, dates and visibility:

{
  "Satzung.pdf": {
    "displayName": "Satzung",
    "originalName": "Satzung.pdf",
    "uploadedAt": "2026-01-01T00:00:00Z",
    "status": "public"
  }
}

SpaceAPI

Read the status:

curl http://127.0.0.1:3000/api/space

Write the status (token required):

curl -X PUT http://127.0.0.1:3000/api/space \
  -H 'Content-Type: application/json' \
  -H 'X-Api-Key: your-secret-token' \
  -d '{"open":true}'

Alternatively via query parameters:

curl "http://127.0.0.1:3000/api/space?open=true&token=your-secret-token"

The status is stored in SPACE_STATUS_FILE and read by the home page.

Docker

Build and start:

docker compose up --build -d

Or manually:

docker build -t dezentrale-website .
docker run --rm -p 3000:3000 \
  -e SITE_URL=http://localhost:3000 \
  -e CONTENT_REPO_URL=https://git.bohmer.systems/moony/dezentrale-content.git \
  -v dezentrale-content:/app/content \
  dezentrale-website

The image uses a multi-stage build: Rust compiles the binary, and the runtime image contains only the binary, public/, plus git and ca-certificates for content syncing.

Project Structure

src/
├── main.rs          # Startup, logging, content sync, HTTP server
├── config.rs        # Environment configuration
├── i18n.rs          # Language detection, translations, language switching
├── content_repo.rs  # Git sync for the content repository
├── blog.rs          # Blog posts, frontmatter, images
├── documents.rs     # Document list and downloads
├── calendar.rs      # ICS parser, recurrence rules, grouping
├── space.rs         # SpaceAPI status
├── routes.rs        # All HTTP routes
├── templates.rs     # Askama template structs
├── rss.rs           # RSS 2.0
├── sitemap.rs       # Sitemap
├── static_files.rs  # Static file serving
└── util.rs          # Date formatting, MIME types, URL encoding
templates/           # Askama templates (embedded at compile time)
messages/            # de.json / en.json (embedded at compile time)
public/              # Static assets, Leaflet, fonts, logos

Design System

The visual style follows the 40C3 style guide. Fonts are stored locally under public/fonts/ and loaded via @font-face – there are no Google Fonts and no system font stacks:

  • Headings: Jersey 10 (uppercase or mixed case),
  • Subheadings: Hack Bold, underlined,
  • Body text: Hack Regular, plus Italic and Bold Italic.

The palette consists of style guide colors 0–7 plus A and B. The original green (#75b44b, color 0) has been replaced by a blue (#5276a2). Text and accents use a lighter blue in dark mode (#7fa2cc) and a darker blue in light mode (#3c638d). The site primarily uses blue and neutral surfaces; the remaining colors are available as accents. Dark mode uses neutral grays from #0a0a0a, light mode uses #ebebeb.

Colors, spacing and components live in public/assets/style.css.

Each page has 3–4 very subtle decorative words (C3E3, 40C3, C3, <3, :3) in the heading font in the background. Selection and placement are derived server-side from the page path and stay fixed in the viewport.

Development

cargo check     # quick type check
cargo test      # unit tests
cargo fmt       # formatting
cargo clippy    # lints

Templates (templates/) and translations (messages/) are embedded at compile time with include_str!. Changes there require a restart or rebuild; static files under public/ are read directly from disk.