Reiseblog mit CMS
This commit is contained in:
commit
7f820a6478
19 changed files with 600 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
.DS_Store
|
||||||
81
README.md
Normal file
81
README.md
Normal 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 …
|
||||||
|
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
## 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
5
astro.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
site: 'https://www.sattelfest.org',
|
||||||
|
});
|
||||||
17
deploy/Caddyfile
Normal file
17
deploy/Caddyfile
Normal 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
|
||||||
|
}
|
||||||
15
deploy/apache-git-sattelfest.conf
Normal file
15
deploy/apache-git-sattelfest.conf
Normal 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>
|
||||||
43
deploy/apache-sattelfest.conf
Normal file
43
deploy/apache-sattelfest.conf
Normal 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>
|
||||||
24
deploy/forgejo-post-receive.sh
Normal file
24
deploy/forgejo-post-receive.sh
Normal 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
28
deploy/post-receive
Normal 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
18
package.json
Normal 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
45
public/admin/config.yml
Normal 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
17
public/admin/index.html
Normal 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
2
public/photos/.gitkeep
Normal 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.
|
||||||
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