Reiseblog mit CMS
This commit is contained in:
commit
7f820a6478
19 changed files with 600 additions and 0 deletions
114
src/components/RouteMap.astro
Normal file
114
src/components/RouteMap.astro
Normal 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: '© 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>
|
||||
14
src/data/tracks/2026-06-01-pyrenaeen.gpx
Normal file
14
src/data/tracks/2026-06-01-pyrenaeen.gpx
Normal 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
54
src/layouts/Base.astro
Normal 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
35
src/lib/gpx.js
Normal 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
43
src/lib/photos.js
Normal 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
26
src/pages/index.astro
Normal 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>
|
||||
15
src/pages/posts/2026-06-01-pyrenaeen.md
Normal file
15
src/pages/posts/2026-06-01-pyrenaeen.md
Normal 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: -->
|
||||
<!--  -->
|
||||
|
||||
[← zurück zur Übersicht](/)
|
||||
Loading…
Add table
Add a link
Reference in a new issue