Reiseblog mit CMS

This commit is contained in:
Burkhard Naumann 2026-05-30 00:09:33 +02:00
commit 7f820a6478
19 changed files with 600 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
.astro/
.DS_Store

81
README.md Normal file
View file

@ -0,0 +1,81 @@
# 🚲 Radweltreise-Blog
Statische Website (kein PHP, keine Datenbank) für ein Reisetagebuch mit
Gesamtroute auf einer Karte. Gebaut mit [Astro](https://astro.build) +
[Leaflet](https://leafletjs.com).
- **Karte oben**, klein Klick aufs Symbol (⤢) macht sie groß (Vollbild).
- **Route** entsteht automatisch aus deinen **GPX-Dateien** (z. B. Komoot-Export).
- **Foto-Marker** entstehen automatisch aus den **GPS-Daten (EXIF)** deiner Fotos.
## Lokal starten
```bash
npm install
npm run dev
```
Dann im Browser http://localhost:4321 öffnen.
Produktions-Build (erzeugt den Ordner `dist/` mit fertigem HTML):
```bash
npm run build
npm run preview # zum lokalen Anschauen des Builds
```
## Neuen Tagebucheintrag schreiben
Lege eine Markdown-Datei in `src/pages/posts/` an, z. B.
`src/pages/posts/2026-06-05-andorra.md`:
```markdown
---
layout: ../../layouts/Base.astro
title: Ankunft in Andorra
date: 2026-06-05
---
# Ankunft in Andorra
Text, Text, Text …
![Bildunterschrift](/photos/IMG_2042.jpg)
```
## GPX-Strecke "hochladen" (Komoot & Co.)
1. In der App die Etappe als **GPX exportieren**.
2. Die Datei in den Ordner **`src/data/tracks/`** legen.
Tipp: Dateinamen mit Datum beginnen (`2026-06-05-andorra.gpx`),
dann ist die Reihenfolge automatisch korrekt.
3. Neu bauen / pushen (siehe unten). Fertig die Linie hängt sich an die Route an.
Es ist kein Upload-Formular nötig: "Hochladen" = Datei in den Ordner legen.
Vom Handy unterwegs am einfachsten mit **Working Copy** (iOS) bzw.
**Termux/MGit** (Android) per Git, oder mit einem **Syncthing/Nextcloud**-Ordner.
## Fotos mit Karten-Marker
Original-Fotos (mit GPS im EXIF) in **`public/photos/`** ablegen. Beim Bauen
werden Koordinaten + Aufnahmezeit ausgelesen und als Marker gesetzt.
⚠️ Nicht über Dienste schicken, die EXIF entfernen (z. B. WhatsApp). Die
Originaldatei verwenden.
## Auf den eigenen Server bringen (Kurzfassung)
Statische Dateien aus `dist/` werden von einem Webserver ohne PHP ausgeliefert.
Empfehlung **Caddy** (automatisches HTTPS):
```
radweltreise.example.com {
root * /var/www/radweltreise/dist
file_server
encode gzip
}
```
Automatischer Rebuild bei Git-Push: einen `post-receive`-Hook im Bare-Repo
auf dem Server `npm ci && npm run build` ausführen lassen. Sag Bescheid,
dann richten wir das Schritt für Schritt ein.

5
astro.config.mjs Normal file
View file

@ -0,0 +1,5 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://www.sattelfest.org',
});

17
deploy/Caddyfile Normal file
View file

@ -0,0 +1,17 @@
# /etc/caddy/Caddyfile
# Statische Auslieferung mit automatischem HTTPS (Let's Encrypt).
www.sattelfest.org {
root * /srv/sattelfest/dist
file_server
encode gzip zstd
# Fotos dürfen lange im Browser-Cache bleiben.
@photos path /photos/*
header @photos Cache-Control "public, max-age=2592000"
}
# Ohne "www" auf "www" weiterleiten.
sattelfest.org {
redir https://www.sattelfest.org{uri} permanent
}

View file

@ -0,0 +1,15 @@
# Nur der ZUSÄTZLICHE VirtualHost für den Git-Host (Forgejo).
# Deine bestehenden vHosts für www/apex bleiben unangetastet.
#
# Ablegen als: /etc/apache2/sites-available/git-sattelfest.conf
# Aktivieren: sudo a2ensite git-sattelfest && sudo systemctl reload apache2
# HTTPS danach mit certbot ergänzen (siehe Anleitung).
<VirtualHost *:80>
ServerName git.sattelfest.org
ProxyPreserveHost On
ProxyRequests Off
ProxyPass / http://localhost:3000/
ProxyPassReverse / http://localhost:3000/
</VirtualHost>

View file

@ -0,0 +1,43 @@
# Apache-Vorlage Alternative zur Caddyfile.
# Ablegen als: /etc/apache2/sites-available/sattelfest.conf
# Aktivieren: sudo a2ensite sattelfest && sudo systemctl reload apache2
#
# HTTPS fügt certbot automatisch hinzu (siehe Anleitung) hier stehen
# zunächst nur die HTTP-(Port-80-)VirtualHosts.
# --- Statische Website ---------------------------------------------------
<VirtualHost *:80>
ServerName www.sattelfest.org
DocumentRoot /srv/sattelfest/dist
<Directory /srv/sattelfest/dist>
Require all granted
Options -Indexes
AllowOverride None
</Directory>
# Fotos lange im Browser-Cache halten
<LocationMatch "^/photos/">
Header set Cache-Control "public, max-age=2592000"
</LocationMatch>
# Komprimierung
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
</IfModule>
</VirtualHost>
# --- Ohne "www" auf "www" weiterleiten -----------------------------------
<VirtualHost *:80>
ServerName sattelfest.org
Redirect permanent / https://www.sattelfest.org/
</VirtualHost>
# --- Git-Host (Forgejo) Reverse Proxy ----------------------------------
<VirtualHost *:80>
ServerName git.sattelfest.org
ProxyPreserveHost On
ProxyRequests Off
ProxyPass / http://localhost:3000/
ProxyPassReverse / http://localhost:3000/
</VirtualHost>

View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Forgejo Build-Hook für die Umgebung mit Apache.
# Eintragen unter: Repo -> Einstellungen -> Git-Hooks -> post-receive.
# (Voraussetzung: Admin hat Git-Hooks aktiviert, siehe Anleitung.)
#
# Ablauf: Code auschecken -> bauen -> Ergebnis in den Apache-DocumentRoot kopieren.
set -euo pipefail
WORKTREE=/srv/sattelfest # Arbeits-/Build-Verzeichnis (Quellcode)
PUBLISH=/var/www/sattelfest # Apache DocumentRoot (fertige Website)
export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
git --work-tree="$WORKTREE" checkout -f main
cd "$WORKTREE"
npm ci
npm run build
# Fertige Dateien in den DocumentRoot spiegeln (--delete entfernt Altes).
rsync -a --delete "$WORKTREE/dist/" "$PUBLISH/"
echo "✅ Deploy fertig: $(date)"

28
deploy/post-receive Normal file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Git-Hook: läuft auf dem Server nach jedem "git push".
# Checkt den Code aus und baut die statische Seite neu.
#
# Installation: nach /srv/git/sattelfest.git/hooks/post-receive kopieren
# und ausführbar machen (chmod +x).
set -euo pipefail
GIT_DIR_PATH=/srv/git/sattelfest.git
WORKTREE=/srv/sattelfest
BRANCH=main
# Node/npm (NodeSource installiert nach /usr/bin) auffindbar machen.
export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
echo "→ Code auschecken …"
git --git-dir="$GIT_DIR_PATH" --work-tree="$WORKTREE" checkout -f "$BRANCH"
cd "$WORKTREE"
echo "→ Abhängigkeiten installieren …"
npm ci
echo "→ Seite bauen …"
npm run build
echo "✅ Deploy fertig: $(date)"

18
package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "radweltreise-blog",
"type": "module",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"astro": "^5.0.0",
"leaflet": "^1.9.4",
"@tmcw/togeojson": "^5.8.1",
"@xmldom/xmldom": "^0.9.6",
"exifr": "^7.1.3"
}
}

45
public/admin/config.yml Normal file
View file

@ -0,0 +1,45 @@
# Decap CMS Konfiguration
# ---------------------------------------------------------------
# Vor dem ersten Login zwei Platzhalter ersetzen:
# 1. repo: <FORGEJO_USER>/sattelfest (Besitzer/Repo in Forgejo)
# 2. app_id: <OAUTH_CLIENT_ID> (aus der OAuth-App in Forgejo)
# base_url ggf. an deine Forgejo-Adresse anpassen.
# ---------------------------------------------------------------
backend:
name: gitea
repo: FORGEJO_USER/sattelfest
base_url: https://git.sattelfest.org
branch: main
app_id: OAUTH_CLIENT_ID
# Wohin Fotos aus dem Editor gespeichert werden.
# Genau dieser Ordner wird beim Bauen nach GPS (EXIF) durchsucht ->
# jedes hochgeladene Foto wird automatisch zu einem Marker auf der Karte.
media_folder: public/photos
public_folder: /photos
# Sprache der Oberfläche
locale: de
collections:
- name: posts
label: Tagebuch
label_singular: Eintrag
folder: src/pages/posts
create: true
slug: "{{year}}-{{month}}-{{day}}-{{slug}}"
extension: md
format: frontmatter
fields:
- { label: Layout, name: layout, widget: hidden, default: "../../layouts/Base.astro" }
- { label: Titel, name: title, widget: string }
- {
label: Datum,
name: date,
widget: datetime,
format: "YYYY-MM-DD",
date_format: "YYYY-MM-DD",
time_format: false,
}
- { label: Text, name: body, widget: markdown }

17
public/admin/index.html Normal file
View file

@ -0,0 +1,17 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Inhalte verwalten · Sattelfest</title>
</head>
<body>
<!--
Decap CMS. Lädt automatisch ./config.yml aus diesem Ordner.
Tipp für "alles auf dem eigenen Server": Statt der CDN-URL kannst du
die Datei decap-cms.js herunterladen, hier nach public/admin/ legen
und unten lokal einbinden (src="./decap-cms.js").
-->
<script src="https://unpkg.com/decap-cms@^3.6.0/dist/decap-cms.js"></script>
</body>
</html>

2
public/photos/.gitkeep Normal file
View file

@ -0,0 +1,2 @@
# Lege hier deine Original-Fotos ab (mit GPS im EXIF).
# Aus jedem Foto wird automatisch ein Marker auf der Karte.

View file

@ -0,0 +1,114 @@
---
import { loadRoute } from '../lib/gpx.js';
import { loadPhotoMarkers } from '../lib/photos.js';
// Beides läuft beim BAUEN (nicht im Browser): GPX -> Linie, Fotos -> Marker.
const route = await loadRoute();
const markers = await loadPhotoMarkers();
const data = JSON.stringify({ route, markers });
---
<div id="routemap-wrap" class="routemap collapsed">
<button id="routemap-toggle" type="button" aria-label="Karte vergrößern" title="Karte vergrößern / verkleinern">⤢</button>
<div id="routemap"></div>
</div>
<script type="application/json" id="routemap-data" set:html={data}></script>
<script>
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
// Behebt die "kaputten Marker-Icons", wenn Leaflet per npm gebündelt wird.
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
L.Marker.prototype.options.icon = L.icon({
iconUrl,
iconRetinaUrl,
shadowUrl,
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
const { route, markers } = JSON.parse(document.getElementById('routemap-data').textContent);
const map = L.map('routemap', { scrollWheelZoom: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap-Mitwirkende',
maxZoom: 19,
}).addTo(map);
const bounds = L.latLngBounds([]);
if (route.features?.length) {
const routeLayer = L.geoJSON(route, { style: { color: '#e63946', weight: 4 } }).addTo(map);
bounds.extend(routeLayer.getBounds());
}
for (const m of markers) {
const marker = L.marker([m.lat, m.lng]).addTo(map);
marker.bindPopup(
`<a href="${m.url}" target="_blank" rel="noopener">` +
`<img src="${m.url}" alt="" style="max-width:220px;display:block;border-radius:6px" />` +
`</a>`
);
bounds.extend(marker.getLatLng());
}
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [20, 20] });
} else {
map.setView([20, 0], 2); // Weltansicht, solange noch keine Daten da sind
}
const wrap = document.getElementById('routemap-wrap');
document.getElementById('routemap-toggle').addEventListener('click', () => {
const expanded = wrap.classList.toggle('expanded');
wrap.classList.toggle('collapsed', !expanded);
map.scrollWheelZoom[expanded ? 'enable' : 'disable']();
// Leaflet muss nach Größenänderung neu zeichnen.
setTimeout(() => map.invalidateSize(), 260);
});
</script>
<style>
.routemap {
position: relative;
width: 100%;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
}
.routemap.collapsed #routemap {
height: 180px;
}
.routemap.expanded {
position: fixed;
inset: 0;
z-index: 1000;
border-radius: 0;
}
.routemap.expanded #routemap {
height: 100vh;
}
#routemap {
width: 100%;
}
#routemap-toggle {
position: absolute;
top: 8px;
right: 8px;
z-index: 1001;
width: 36px;
height: 36px;
font-size: 18px;
line-height: 1;
cursor: pointer;
background: #fff;
border: 1px solid #ccc;
border-radius: 8px;
}
</style>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Beispiel" xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<name>Beispiel-Etappe Pyrenäen</name>
<trkseg>
<trkpt lat="42.6976" lon="0.8589"><ele>1500</ele><time>2026-06-01T07:00:00Z</time></trkpt>
<trkpt lat="42.7050" lon="0.8800"><ele>1620</ele><time>2026-06-01T07:30:00Z</time></trkpt>
<trkpt lat="42.7180" lon="0.9100"><ele>1810</ele><time>2026-06-01T08:10:00Z</time></trkpt>
<trkpt lat="42.7320" lon="0.9450"><ele>2050</ele><time>2026-06-01T09:00:00Z</time></trkpt>
<trkpt lat="42.7510" lon="0.9900"><ele>1740</ele><time>2026-06-01T10:00:00Z</time></trkpt>
<trkpt lat="42.7700" lon="1.0400"><ele>1480</ele><time>2026-06-01T11:00:00Z</time></trkpt>
</trkseg>
</trk>
</gpx>

54
src/layouts/Base.astro Normal file
View file

@ -0,0 +1,54 @@
---
const { title, frontmatter } = Astro.props;
const pageTitle = frontmatter?.title ?? title ?? 'Radweltreise';
---
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Tagebuch unserer Fahrradweltreise" />
<title>{pageTitle}</title>
</head>
<body>
<header>
<h1><a href="/">🚲 Radweltreise</a></h1>
</header>
<main>
<slot />
</main>
<style is:global>
:root {
color-scheme: light dark;
}
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 720px;
margin: 0 auto;
padding: 1rem;
line-height: 1.6;
}
img {
max-width: 100%;
height: auto;
border-radius: 8px;
}
a {
color: #e63946;
}
header h1 a {
text-decoration: none;
color: inherit;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 0.4rem 0;
}
</style>
</body>
</html>

35
src/lib/gpx.js Normal file
View file

@ -0,0 +1,35 @@
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import { DOMParser } from '@xmldom/xmldom';
import { gpx } from '@tmcw/togeojson';
// Hier landen deine Komoot-/App-Exporte. Dateinamen am besten mit Datum
// beginnen lassen (z. B. 2026-06-01-pyrenaeen.gpx), dann stimmt die Reihenfolge.
const TRACKS_DIR = path.join(process.cwd(), 'src/data/tracks');
// Liest alle .gpx-Dateien und fügt ihre Streckenlinien zu einer
// GeoJSON-FeatureCollection zusammen (= deine Gesamtroute).
export async function loadRoute() {
let files = [];
try {
files = (await readdir(TRACKS_DIR)).filter((f) => f.toLowerCase().endsWith('.gpx'));
} catch {
return { type: 'FeatureCollection', features: [] };
}
files.sort();
const features = [];
for (const file of files) {
const xml = await readFile(path.join(TRACKS_DIR, file), 'utf-8');
const doc = new DOMParser().parseFromString(xml, 'text/xml');
const geo = gpx(doc);
for (const feature of geo.features) {
const type = feature.geometry?.type;
if (type === 'LineString' || type === 'MultiLineString') {
feature.properties = { ...feature.properties, source: file };
features.push(feature);
}
}
}
return { type: 'FeatureCollection', features };
}

43
src/lib/photos.js Normal file
View file

@ -0,0 +1,43 @@
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import exifr from 'exifr';
// Fotos hier ablegen; sie werden 1:1 ausgeliefert (URL /photos/<datei>).
// Wichtig: Original-Dateien verwenden manche Cloud-/Chat-Dienste löschen die GPS-Daten.
const PHOTOS_DIR = path.join(process.cwd(), 'public/photos');
// Liest aus jedem Foto GPS + Aufnahmezeitpunkt und liefert Karten-Marker.
export async function loadPhotoMarkers() {
let files = [];
try {
files = (await readdir(PHOTOS_DIR)).filter((f) => /\.(jpe?g|tiff?|heic|heif)$/i.test(f));
} catch {
return [];
}
const markers = [];
for (const file of files) {
const buffer = await readFile(path.join(PHOTOS_DIR, file));
let gps = null;
try {
gps = await exifr.gps(buffer);
} catch {
gps = null;
}
if (!gps || gps.latitude == null || gps.longitude == null) continue;
let date = null;
try {
const meta = await exifr.parse(buffer, ['DateTimeOriginal']);
if (meta?.DateTimeOriginal) date = new Date(meta.DateTimeOriginal).toISOString();
} catch {
date = null;
}
markers.push({ file, url: `/photos/${file}`, lat: gps.latitude, lng: gps.longitude, date });
}
markers.sort((a, b) => (a.date ?? '').localeCompare(b.date ?? ''));
return markers;
}

26
src/pages/index.astro Normal file
View file

@ -0,0 +1,26 @@
---
import Base from '../layouts/Base.astro';
import RouteMap from '../components/RouteMap.astro';
// Alle Tagebuch-Einträge einsammeln (Markdown-Dateien im Ordner posts/).
const modules = import.meta.glob('./posts/*.md', { eager: true });
const posts = Object.values(modules)
.map((m) => ({ url: m.url, ...m.frontmatter }))
.sort((a, b) => new Date(b.date) - new Date(a.date));
---
<Base title="Radweltreise">
<RouteMap />
<h2>Tagebuch</h2>
{posts.length === 0 && <p>Noch keine Einträge der erste kommt bald!</p>}
<ul>
{posts.map((p) => (
<li>
<a href={p.url}>
<strong>{p.date}</strong> — {p.title}
</a>
</li>
))}
</ul>
</Base>

View file

@ -0,0 +1,15 @@
---
layout: ../../layouts/Base.astro
title: Über die Pyrenäen
date: 2026-06-01
---
# Über die Pyrenäen
Heute 1400 Höhenmeter, die Beine sind tot, aber die Aussicht war unbezahlbar.
Oben am Pass gab es Käse aus dem Rucksack und einen Espresso, der nach Sieg schmeckte.
<!-- Fotos legst du in public/photos/ ab und verlinkst sie so: -->
<!-- ![Blick vom Pass](/photos/IMG_2031.jpg) -->
[← zurück zur Übersicht](/)