Add Markdown chapter content

This commit is contained in:
André Rüegger 2026-06-01 10:23:33 +02:00
parent 7626417c46
commit 4de130e608
9 changed files with 236 additions and 39 deletions

View File

@ -311,6 +311,7 @@
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<script src="js/markdown.js"></script>
<script src="js/blog-post.js"></script> <script src="js/blog-post.js"></script>
</body> </body>
</html> </html>

View File

@ -194,6 +194,70 @@ textarea:focus {
box-shadow: var(--shadow-3); box-shadow: var(--shadow-3);
} }
.markdown-content {
color: var(--app-text) !important;
line-height: 1.65;
}
.markdown-content > *:first-child {
margin-top: 0;
}
.markdown-content > *:last-child {
margin-bottom: 0;
}
.markdown-content h3,
.markdown-content h4,
.markdown-content h5 {
color: var(--app-text) !important;
margin: var(--size-3) 0 var(--size-2);
}
.markdown-content p,
.markdown-content li {
color: var(--app-text) !important;
}
.markdown-content a {
color: var(--app-link) !important;
}
.markdown-content img {
max-width: 100%;
height: auto;
border-radius: 6px;
margin: var(--size-2) 0;
}
.markdown-content code {
background: var(--surface-3);
border: 1px solid var(--app-border);
border-radius: 4px;
padding: 0.1rem 0.3rem;
}
.markdown-content pre {
overflow-x: auto;
background: var(--surface-3);
border: 1px solid var(--app-border);
border-radius: 6px;
padding: var(--size-3);
}
.markdown-preview {
margin-top: var(--size-2);
padding: var(--size-3);
border: 1px solid var(--app-border);
border-radius: 6px;
background: var(--app-panel-soft) !important;
}
.markdown-empty {
color: var(--app-text-muted) !important;
font-style: italic;
}
/* Mobile-first approach */ /* Mobile-first approach */
.map-container { .map-container {
height: 100vh; height: 100vh;

View File

@ -381,6 +381,7 @@
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<script src="js/markdown.js"></script>
<script src="js/journey-edit.js"></script> <script src="js/journey-edit.js"></script>
</body> </body>
</html> </html>

View File

@ -32,10 +32,9 @@ function renderJourney() {
currentJourney.markers.forEach((marker, idx) => { currentJourney.markers.forEach((marker, idx) => {
const title = marker.title || `Chapter ${idx + 1}`; const title = marker.title || `Chapter ${idx + 1}`;
const date = marker.date ? `<span class="marker-date">${new Date(marker.date).toLocaleDateString()}</span>` : ''; const date = marker.date ? `<span class="marker-date">${new Date(marker.date).toLocaleDateString()}</span>` : '';
const content = marker.description ? escapeHtml(marker.description).replace(/\n/g, '<br>') : ''; const content = marker.description ? renderMarkdown(marker.description) : '';
marker.images = getMarkerImages(marker); marker.images = getMarkerImages(marker);
const images = getMarkerImagesHtml(marker.images, idx, canEdit); const images = getMarkerImagesHtml(marker.images, idx, canEdit);
const video = marker.videoUrl ? `<iframe src="${marker.videoUrl}" frameborder="0" allowfullscreen></iframe>` : '';
chaptersHtml += ` chaptersHtml += `
<div class="chapter" data-marker-id="${marker.id || idx}"> <div class="chapter" data-marker-id="${marker.id || idx}">
<div class="chapter-header"> <div class="chapter-header">
@ -44,8 +43,7 @@ function renderJourney() {
</div> </div>
${images} ${images}
${canEdit ? getImageToolsHtml(idx) : ''} ${canEdit ? getImageToolsHtml(idx) : ''}
${video} <div class="chapter-content markdown-content">${content}</div>
<div class="chapter-content">${content}</div>
</div> </div>
`; `;
}); });
@ -63,7 +61,7 @@ function renderJourney() {
${currentJourney.visibility === 'shared' && !isOwner ? `<span class="badge">${canEdit ? 'Shared edit' : 'Shared'}</span>` : ''} ${currentJourney.visibility === 'shared' && !isOwner ? `<span class="badge">${canEdit ? 'Shared edit' : 'Shared'}</span>` : ''}
</div> </div>
${currentJourney.image ? `<img class="post-image" src="${currentJourney.image}" alt="${currentJourney.title}">` : ''} ${currentJourney.image ? `<img class="post-image" src="${currentJourney.image}" alt="${currentJourney.title}">` : ''}
<div class="post-description">${escapeHtml(currentJourney.description).replace(/\n/g, '<br>')}</div> <div class="post-description markdown-content">${renderMarkdown(currentJourney.description)}</div>
${chaptersHtml} ${chaptersHtml}
${canEdit || isOwner ? ` ${canEdit || isOwner ? `
<div style="margin-top: var(--size-4); display: flex; gap: var(--size-2);"> <div style="margin-top: var(--size-4); display: flex; gap: var(--size-2);">

View File

@ -144,6 +144,11 @@ function renderMarkerImages(marker, idx) {
`; `;
} }
function renderMarkdownPreview(markdown) {
const html = renderMarkdown(markdown || '');
return html || '<p class="markdown-empty">Nothing to preview yet.</p>';
}
async function uploadMarkerImages(files) { async function uploadMarkerImages(files) {
if (!files.length) return []; if (!files.length) return [];
@ -186,17 +191,16 @@ function renderMarkers() {
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Description</label> <label>Description</label>
<textarea class="marker-description" data-index="${idx}" rows="3" placeholder="Write about this chapter...">${escapeHtml(marker.description || '')}</textarea> <textarea class="marker-description" data-index="${idx}" rows="5" placeholder="Describe this chapter. You can use Markdown like **bold**, [link](https://example.com), or ![image](https://example.com/image.jpg).">${escapeHtml(marker.description || '')}</textarea>
<div class="markdown-preview markdown-content" data-preview-index="${idx}">
${renderMarkdownPreview(marker.description)}
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Images</label> <label>Images</label>
<input type="file" class="marker-images" data-index="${idx}" accept="image/*" multiple> <input type="file" class="marker-images" data-index="${idx}" accept="image/*" multiple>
${renderMarkerImages(marker, idx)} ${renderMarkerImages(marker, idx)}
</div> </div>
<div class="form-group">
<label>Video URL</label>
<input type="text" class="marker-video" data-index="${idx}" value="${escapeHtml(marker.videoUrl || '')}" placeholder="https://youtu.be/...">
</div>
</div> </div>
`).join(''); `).join('');
@ -226,6 +230,8 @@ function renderMarkers() {
textarea.addEventListener('input', (e) => { textarea.addEventListener('input', (e) => {
const idx = parseInt(textarea.dataset.index); const idx = parseInt(textarea.dataset.index);
markersData[idx].description = textarea.value; markersData[idx].description = textarea.value;
const preview = document.querySelector(`.markdown-preview[data-preview-index="${idx}"]`);
if (preview) preview.innerHTML = renderMarkdownPreview(textarea.value);
}); });
}); });
document.querySelectorAll('.marker-images').forEach(input => { document.querySelectorAll('.marker-images').forEach(input => {
@ -249,12 +255,6 @@ function renderMarkers() {
renderMarkers(); renderMarkers();
}); });
}); });
document.querySelectorAll('.marker-video').forEach(input => {
input.addEventListener('input', (e) => {
const idx = parseInt(input.dataset.index);
markersData[idx].videoUrl = input.value;
});
});
} }
// ==================== LOAD JOURNEY ==================== // ==================== LOAD JOURNEY ====================
@ -307,8 +307,7 @@ async function saveJourney(event) {
title: m.title || '', title: m.title || '',
date: m.date || '', date: m.date || '',
description: m.description || '', description: m.description || '',
images: m.images || [], images: m.images || []
videoUrl: m.videoUrl || ''
})); }));
const payload = { title, description, markers }; const payload = { title, description, markers };
@ -356,8 +355,7 @@ function addMarker() {
title: 'New Chapter', title: 'New Chapter',
date: '', date: '',
description: '', description: '',
images: [], images: []
videoUrl: ''
}); });
renderMarkers(); renderMarkers();
} }

View File

@ -12,7 +12,6 @@ function displayJourney(journey) {
<p class="date">${marker.content.date}</p> <p class="date">${marker.content.date}</p>
<p class="location">${marker.lngLat.lng.toFixed(4)}, ${marker.lngLat.lat.toFixed(4)}</p> <p class="location">${marker.lngLat.lng.toFixed(4)}, ${marker.lngLat.lat.toFixed(4)}</p>
<p>${marker.content.text}</p> <p>${marker.content.text}</p>
${marker.content.videoUrl ? `<iframe src="${marker.content.videoUrl}" frameborder="0"></iframe>` : ''}
<div class="images-container"> <div class="images-container">
${marker.content.images.map(img => `<img src="${img}" alt="Event image">`).join('')} ${marker.content.images.map(img => `<img src="${img}" alt="Event image">`).join('')}
</div> </div>

View File

@ -138,6 +138,12 @@
}); });
} }
function updateMarkerMarkdownPreview(markdown) {
const preview = document.getElementById("marker-markdown-preview");
if (!preview) return;
preview.innerHTML = renderMarkdown(markdown || "") || '<p class="markdown-empty">Nothing to preview yet.</p>';
}
async function uploadMarkerImages(files) { async function uploadMarkerImages(files) {
if (!files.length) return []; if (!files.length) return [];
@ -166,8 +172,7 @@
content.date || ""; content.date || "";
document.getElementById("marker-text").value = document.getElementById("marker-text").value =
content.description || ""; content.description || "";
document.getElementById("video-url").value = updateMarkerMarkdownPreview(content.description || "");
content.videoUrl || "";
document.getElementById("marker-images").value = ""; document.getElementById("marker-images").value = "";
renderMarkerImages(content.images || []); renderMarkerImages(content.images || []);
document.getElementById("marker-coords").value = document.getElementById("marker-coords").value =
@ -189,7 +194,6 @@
const title = document.getElementById("marker-title").value || "Untitled"; const title = document.getElementById("marker-title").value || "Untitled";
const date = document.getElementById("marker-date").value; const date = document.getElementById("marker-date").value;
const description = document.getElementById("marker-text").value; const description = document.getElementById("marker-text").value;
const videoUrl = document.getElementById("video-url").value;
const imageInput = document.getElementById("marker-images"); const imageInput = document.getElementById("marker-images");
const existingImages = activeMarker._content?.images || []; const existingImages = activeMarker._content?.images || [];
let images = existingImages; let images = existingImages;
@ -207,7 +211,7 @@
activeMarker.setPopupContent(`<strong>${title}</strong>`); activeMarker.setPopupContent(`<strong>${title}</strong>`);
// Store content for saving // Store content for saving
activeMarker._content = { title, date, description, videoUrl, images }; activeMarker._content = { title, date, description, images };
// Update the list item in the sidebar // Update the list item in the sidebar
const latlng = activeMarker.getLatLng(); const latlng = activeMarker.getLatLng();
@ -353,7 +357,6 @@
title: content.title || "", title: content.title || "",
date: content.date || "", date: content.date || "",
description: content.description || "", description: content.description || "",
videoUrl: content.videoUrl || "",
images: content.images || [], images: content.images || [],
}; };
}); });
@ -436,7 +439,6 @@
title: m.title, title: m.title,
date: m.date, date: m.date,
description: m.description, description: m.description,
videoUrl: m.videoUrl,
images: m.images || [], images: m.images || [],
}); });
marker._content = { ...m }; marker._content = { ...m };
@ -469,7 +471,6 @@
title: displayTitle, title: displayTitle,
date: m.date, date: m.date,
description: m.description, description: m.description,
videoUrl: m.videoUrl,
images: m.images || [], images: m.images || [],
}); });
marker._content = { marker._content = {
@ -652,6 +653,9 @@
setMode("create"); setMode("create");
showToast("Click on the map to add a marker"); showToast("Click on the map to add a marker");
}); });
document
.getElementById("marker-text")
.addEventListener("input", (e) => updateMarkerMarkdownPreview(e.target.value));
} }
// Expose functions globally (optional) // Expose functions globally (optional)

137
js/markdown.js Normal file
View File

@ -0,0 +1,137 @@
(function () {
const tokenPattern = /%%MDTOKEN(\d+)%%/g;
function escapeHtml(value) {
return String(value || "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
}
function sanitizeUrl(value) {
const url = String(value || "").trim().replace(/[\u0000-\u001f\u007f\s]+/g, "");
if (/^(https?:\/\/|\/|\.\/|\.\.\/|uploads\/|mailto:|#)/i.test(url)) {
return escapeHtml(url);
}
return "#";
}
function storeToken(tokens, html) {
const token = `%%MDTOKEN${tokens.length}%%`;
tokens.push(html);
return token;
}
function renderInline(markdown) {
const tokens = [];
let text = String(markdown || "");
text = text.replace(/`([^`]+)`/g, (_, code) =>
storeToken(tokens, `<code>${escapeHtml(code)}</code>`),
);
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) =>
storeToken(
tokens,
`<img src="${sanitizeUrl(url)}" alt="${escapeHtml(alt)}" loading="lazy">`,
),
);
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) =>
storeToken(
tokens,
`<a href="${sanitizeUrl(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`,
),
);
text = escapeHtml(text)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/_([^_]+)_/g, "<em>$1</em>");
return text.replace(tokenPattern, (_, index) => tokens[Number(index)] || "");
}
function renderList(lines) {
const items = lines
.map((line) => line.replace(/^\s*[-*]\s+/, ""))
.map((line) => `<li>${renderInline(line)}</li>`)
.join("");
return `<ul>${items}</ul>`;
}
function renderMarkdown(markdown) {
const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
const blocks = [];
let paragraph = [];
let list = [];
let codeBlock = null;
function flushParagraph() {
if (!paragraph.length) return;
blocks.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
paragraph = [];
}
function flushList() {
if (!list.length) return;
blocks.push(renderList(list));
list = [];
}
lines.forEach((line) => {
if (line.trim().startsWith("```")) {
if (codeBlock) {
blocks.push(`<pre><code>${escapeHtml(codeBlock.join("\n"))}</code></pre>`);
codeBlock = null;
} else {
flushParagraph();
flushList();
codeBlock = [];
}
return;
}
if (codeBlock) {
codeBlock.push(line);
return;
}
if (!line.trim()) {
flushParagraph();
flushList();
return;
}
const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
flushParagraph();
flushList();
const level = heading[1].length + 2;
blocks.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
return;
}
if (/^\s*[-*]\s+/.test(line)) {
flushParagraph();
list.push(line);
return;
}
flushList();
paragraph.push(line.trim());
});
flushParagraph();
flushList();
if (codeBlock) {
blocks.push(`<pre><code>${escapeHtml(codeBlock.join("\n"))}</code></pre>`);
}
return blocks.join("");
}
window.renderMarkdown = renderMarkdown;
})();

View File

@ -853,18 +853,12 @@
<label for="marker-text">Description</label> <label for="marker-text">Description</label>
<textarea <textarea
id="marker-text" id="marker-text"
placeholder="What happened here?" placeholder="Describe this marker. You can use Markdown like **bold**, [link](https://example.com), or ![image](https://example.com/image.jpg)."
></textarea> ></textarea>
</div> <div
<div class="form-group"> id="marker-markdown-preview"
<label for="video-url" class="markdown-preview markdown-content"
>Video URL (optional)</label ></div>
>
<input
type="text"
id="video-url"
placeholder="https://youtu.be/..."
/>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="marker-images">Images</label> <label for="marker-images">Images</label>
@ -913,6 +907,7 @@
</div> </div>
<div id="toast" class="toast"><p id="toast-message"></p></div> <div id="toast" class="toast"><p id="toast-message"></p></div>
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<script src="js/markdown.js"></script>
<script src="js/map-page.js"></script> <script src="js/map-page.js"></script>
</body> </body>
</html> </html>