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

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](/)