Add Markdown chapter content
This commit is contained in:
parent
7626417c46
commit
4de130e608
@ -311,6 +311,7 @@
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="js/auth.js"></script>
|
||||
<script src="js/markdown.js"></script>
|
||||
<script src="js/blog-post.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -194,6 +194,70 @@ textarea:focus {
|
||||
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 */
|
||||
.map-container {
|
||||
height: 100vh;
|
||||
|
||||
@ -381,6 +381,7 @@
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="js/auth.js"></script>
|
||||
<script src="js/markdown.js"></script>
|
||||
<script src="js/journey-edit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -32,10 +32,9 @@ function renderJourney() {
|
||||
currentJourney.markers.forEach((marker, idx) => {
|
||||
const title = marker.title || `Chapter ${idx + 1}`;
|
||||
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);
|
||||
const images = getMarkerImagesHtml(marker.images, idx, canEdit);
|
||||
const video = marker.videoUrl ? `<iframe src="${marker.videoUrl}" frameborder="0" allowfullscreen></iframe>` : '';
|
||||
chaptersHtml += `
|
||||
<div class="chapter" data-marker-id="${marker.id || idx}">
|
||||
<div class="chapter-header">
|
||||
@ -44,8 +43,7 @@ function renderJourney() {
|
||||
</div>
|
||||
${images}
|
||||
${canEdit ? getImageToolsHtml(idx) : ''}
|
||||
${video}
|
||||
<div class="chapter-content">${content}</div>
|
||||
<div class="chapter-content markdown-content">${content}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
@ -63,7 +61,7 @@ function renderJourney() {
|
||||
${currentJourney.visibility === 'shared' && !isOwner ? `<span class="badge">${canEdit ? 'Shared edit' : 'Shared'}</span>` : ''}
|
||||
</div>
|
||||
${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}
|
||||
${canEdit || isOwner ? `
|
||||
<div style="margin-top: var(--size-4); display: flex; gap: var(--size-2);">
|
||||
|
||||
@ -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) {
|
||||
if (!files.length) return [];
|
||||
|
||||
@ -186,17 +191,16 @@ function renderMarkers() {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 .">${escapeHtml(marker.description || '')}</textarea>
|
||||
<div class="markdown-preview markdown-content" data-preview-index="${idx}">
|
||||
${renderMarkdownPreview(marker.description)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Images</label>
|
||||
<input type="file" class="marker-images" data-index="${idx}" accept="image/*" multiple>
|
||||
${renderMarkerImages(marker, idx)}
|
||||
</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>
|
||||
`).join('');
|
||||
|
||||
@ -226,6 +230,8 @@ function renderMarkers() {
|
||||
textarea.addEventListener('input', (e) => {
|
||||
const idx = parseInt(textarea.dataset.index);
|
||||
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 => {
|
||||
@ -249,12 +255,6 @@ function 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 ====================
|
||||
@ -307,8 +307,7 @@ async function saveJourney(event) {
|
||||
title: m.title || '',
|
||||
date: m.date || '',
|
||||
description: m.description || '',
|
||||
images: m.images || [],
|
||||
videoUrl: m.videoUrl || ''
|
||||
images: m.images || []
|
||||
}));
|
||||
|
||||
const payload = { title, description, markers };
|
||||
@ -356,8 +355,7 @@ function addMarker() {
|
||||
title: 'New Chapter',
|
||||
date: '',
|
||||
description: '',
|
||||
images: [],
|
||||
videoUrl: ''
|
||||
images: []
|
||||
});
|
||||
renderMarkers();
|
||||
}
|
||||
|
||||
@ -12,7 +12,6 @@ function displayJourney(journey) {
|
||||
<p class="date">${marker.content.date}</p>
|
||||
<p class="location">${marker.lngLat.lng.toFixed(4)}, ${marker.lngLat.lat.toFixed(4)}</p>
|
||||
<p>${marker.content.text}</p>
|
||||
${marker.content.videoUrl ? `<iframe src="${marker.content.videoUrl}" frameborder="0"></iframe>` : ''}
|
||||
<div class="images-container">
|
||||
${marker.content.images.map(img => `<img src="${img}" alt="Event image">`).join('')}
|
||||
</div>
|
||||
|
||||
@ -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) {
|
||||
if (!files.length) return [];
|
||||
|
||||
@ -166,8 +172,7 @@
|
||||
content.date || "";
|
||||
document.getElementById("marker-text").value =
|
||||
content.description || "";
|
||||
document.getElementById("video-url").value =
|
||||
content.videoUrl || "";
|
||||
updateMarkerMarkdownPreview(content.description || "");
|
||||
document.getElementById("marker-images").value = "";
|
||||
renderMarkerImages(content.images || []);
|
||||
document.getElementById("marker-coords").value =
|
||||
@ -189,7 +194,6 @@
|
||||
const title = document.getElementById("marker-title").value || "Untitled";
|
||||
const date = document.getElementById("marker-date").value;
|
||||
const description = document.getElementById("marker-text").value;
|
||||
const videoUrl = document.getElementById("video-url").value;
|
||||
const imageInput = document.getElementById("marker-images");
|
||||
const existingImages = activeMarker._content?.images || [];
|
||||
let images = existingImages;
|
||||
@ -207,7 +211,7 @@
|
||||
activeMarker.setPopupContent(`<strong>${title}</strong>`);
|
||||
|
||||
// Store content for saving
|
||||
activeMarker._content = { title, date, description, videoUrl, images };
|
||||
activeMarker._content = { title, date, description, images };
|
||||
|
||||
// Update the list item in the sidebar
|
||||
const latlng = activeMarker.getLatLng();
|
||||
@ -353,7 +357,6 @@
|
||||
title: content.title || "",
|
||||
date: content.date || "",
|
||||
description: content.description || "",
|
||||
videoUrl: content.videoUrl || "",
|
||||
images: content.images || [],
|
||||
};
|
||||
});
|
||||
@ -436,7 +439,6 @@
|
||||
title: m.title,
|
||||
date: m.date,
|
||||
description: m.description,
|
||||
videoUrl: m.videoUrl,
|
||||
images: m.images || [],
|
||||
});
|
||||
marker._content = { ...m };
|
||||
@ -469,7 +471,6 @@
|
||||
title: displayTitle,
|
||||
date: m.date,
|
||||
description: m.description,
|
||||
videoUrl: m.videoUrl,
|
||||
images: m.images || [],
|
||||
});
|
||||
marker._content = {
|
||||
@ -652,6 +653,9 @@
|
||||
setMode("create");
|
||||
showToast("Click on the map to add a marker");
|
||||
});
|
||||
document
|
||||
.getElementById("marker-text")
|
||||
.addEventListener("input", (e) => updateMarkerMarkdownPreview(e.target.value));
|
||||
}
|
||||
|
||||
// Expose functions globally (optional)
|
||||
|
||||
137
js/markdown.js
Normal file
137
js/markdown.js
Normal file
@ -0,0 +1,137 @@
|
||||
(function () {
|
||||
const tokenPattern = /%%MDTOKEN(\d+)%%/g;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "").replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[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;
|
||||
})();
|
||||
@ -853,18 +853,12 @@
|
||||
<label for="marker-text">Description</label>
|
||||
<textarea
|
||||
id="marker-text"
|
||||
placeholder="What happened here?"
|
||||
placeholder="Describe this marker. You can use Markdown like **bold**, [link](https://example.com), or ."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="video-url"
|
||||
>Video URL (optional)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="video-url"
|
||||
placeholder="https://youtu.be/..."
|
||||
/>
|
||||
<div
|
||||
id="marker-markdown-preview"
|
||||
class="markdown-preview markdown-content"
|
||||
></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="marker-images">Images</label>
|
||||
@ -913,6 +907,7 @@
|
||||
</div>
|
||||
<div id="toast" class="toast"><p id="toast-message"></p></div>
|
||||
<script src="js/auth.js"></script>
|
||||
<script src="js/markdown.js"></script>
|
||||
<script src="js/map-page.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user