Centralize page JavaScript and fix all-journeys map

This commit is contained in:
André Rüegger 2026-06-01 09:24:00 +02:00
parent 4bf7dc2586
commit 7626417c46
10 changed files with 1597 additions and 1529 deletions

View File

@ -264,88 +264,6 @@
<div id="toast" class="toast"></div>
<script src="js/auth.js"></script>
<script>
let allJourneys = [];
// ==================== LOAD JOURNEYS ====================
async function loadJourneys() {
try {
const res = await fetch(`${API_BASE}/journeys`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to fetch journeys');
const journeys = await res.json();
allJourneys = journeys;
renderJourneys(journeys);
} catch (err) {
console.error(err);
document.getElementById('posts-grid').innerHTML = '<p class="empty-state">Failed to load journeys. Make sure the backend is running.</p>';
}
}
function renderJourneys(journeys) {
const container = document.getElementById('posts-grid');
if (!journeys.length) {
container.innerHTML = `
<div class="empty-state">
<p>No journeys yet. Create one on the map!</p>
</div>
`;
return;
}
container.innerHTML = journeys.map(journey => `
<article class="post-card">
${journey.image ? `<img class="post-card-image" src="${journey.image}" alt="${journey.title}">` : '<div class="post-card-image" style="background: var(--surface-3); display: flex; align-items: center; justify-content: center;"><i class="fas fa-image" style="font-size: 3rem; color: var(--gray-5);"></i></div>'}
<div class="post-card-content">
<h2 class="post-card-title"><a href="blog-post.html?id=${journey.id}">${escapeHtml(journey.title)}</a></h2>
<div class="post-card-meta">
<i class="fas fa-calendar-alt"></i> ${new Date(journey.created_at).toLocaleDateString()}
${journey.markers ? `<span style="margin-left: 12px;"><i class="fas fa-map-marker-alt"></i> ${journey.markers.length} chapters</span>` : ''}
${getJourneyBadges(journey)}
</div>
<div class="post-card-excerpt">${escapeHtml(journey.description || journey.markers?.[0]?.text?.substring(0, 150) + '…')}</div>
</div>
</article>
`).join('');
}
function getJourneyBadges(journey) {
const badges = [];
const isOwner = journey.owner_id === currentUser?.id;
const isShared = journey.visibility === 'shared' && !isOwner;
if (journey.visibility === 'public') badges.push('Public');
if (isShared) badges.push(journey.can_edit ? 'Shared edit' : 'Shared');
return badges.map((badge) => `<span class="badge">${badge}</span>`).join('');
}
function filterJourneys(filter) {
if (filter === 'all') {
renderJourneys(allJourneys);
} else if (filter === 'own') {
renderJourneys(allJourneys.filter(j => j.owner_id === currentUser?.id));
} else if (filter === 'shared') {
renderJourneys(allJourneys.filter(j => j.owner_id !== currentUser?.id && j.visibility === 'shared'));
} else if (filter === 'public') {
renderJourneys(allJourneys.filter(j => j.visibility === 'public'));
}
}
// ==================== INITIALIZATION ====================
document.addEventListener('DOMContentLoaded', async () => {
const authenticated = await checkAuthAndRedirect();
if (!authenticated) return;
updateUserMenu();
loadJourneys();
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
filterJourneys(btn.dataset.filter);
});
});
});
</script>
<script src="js/blog-list.js"></script>
</body>
</html>

View File

@ -311,358 +311,6 @@
<div id="toast" class="toast"></div>
<script src="js/auth.js"></script>
<script>
let currentJourney = null;
const urlParams = new URLSearchParams(window.location.search);
const journeyId = urlParams.get('id');
// ==================== LOAD JOURNEY ====================
async function loadJourney() {
if (!journeyId) {
window.location.href = 'blog-list.html';
return;
}
try {
const res = await fetch(`${API_BASE}/journeys/${journeyId}`, { credentials: 'include' });
if (!res.ok) throw new Error('Journey not found');
currentJourney = await res.json();
renderJourney();
loadComments();
} catch (err) {
showToast('Error loading journey', true);
setTimeout(() => window.location.href = 'blog-list.html', 2000);
}
}
function renderJourney() {
const container = document.getElementById('post-content');
const isOwner = currentUser && currentUser.id === currentJourney.owner_id;
const canEdit = canEditJourney();
// Build chapters from markers
let chaptersHtml = '';
if (currentJourney.markers && currentJourney.markers.length) {
chaptersHtml = '<div class="chapters">';
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>') : '';
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">
<h3>${escapeHtml(title)}</h3>
${date}
</div>
${images}
${canEdit ? getImageToolsHtml(idx) : ''}
${video}
<div class="chapter-content">${content}</div>
</div>
`;
});
chaptersHtml += '</div>';
} else {
chaptersHtml = '<p>No chapters yet.</p>';
}
container.innerHTML = `
<article class="post-card">
<h1 class="post-title">${escapeHtml(currentJourney.title)}</h1>
<div class="post-meta">
<i class="fas fa-calendar-alt"></i> ${new Date(currentJourney.created_at).toLocaleDateString()}
${currentJourney.visibility === 'public' ? '<span class="badge">Public</span>' : ''}
${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>
${chaptersHtml}
${canEdit || isOwner ? `
<div style="margin-top: var(--size-4); display: flex; gap: var(--size-2);">
${canEdit ? `<a href="journey-edit.html?id=${currentJourney.id}" class="btn btn-sm"><i class="fas fa-edit"></i> Edit</a>` : ''}
${isOwner ? `<button id="delete-journey-btn" class="btn btn-danger btn-sm"><i class="fas fa-trash"></i> Delete</button>` : ''}
</div>
` : ''}
</article>
`;
if (isOwner) {
document.getElementById('delete-journey-btn')?.addEventListener('click', deleteJourney);
}
if (canEdit) {
attachChapterImageControls();
}
}
function getUploadUrl(path) {
if (!path) return '';
if (path.startsWith('http')) return path;
return API_BASE.replace('/api', path);
}
function canEditJourney() {
if (!currentUser || !currentJourney) return false;
if (currentUser.id === currentJourney.owner_id) return true;
return currentJourney.visibility === 'shared'
&& Array.isArray(currentJourney.shared_edit)
&& currentJourney.shared_edit.includes(currentUser.id);
}
function canViewJourney() {
if (!currentUser || !currentJourney) return false;
if (currentUser.id === currentJourney.owner_id) return true;
if (currentJourney.visibility === 'public') return true;
return currentJourney.visibility === 'shared'
&& (
(Array.isArray(currentJourney.shared_read) && currentJourney.shared_read.includes(currentUser.id))
|| (Array.isArray(currentJourney.shared_edit) && currentJourney.shared_edit.includes(currentUser.id))
);
}
function getMarkerImages(marker) {
return marker.images || (marker.image ? [marker.image] : []);
}
function getMarkerImagesHtml(images, markerIndex, canEdit) {
if (!images || !images.length) return '';
const imageHtml = images.map((image, imageIndex) => {
const url = typeof image === 'string' ? image : image.url;
const alt = typeof image === 'string' ? 'Journey image' : image.originalName || 'Journey image';
if (!url) return '';
return `
<div class="chapter-image-item">
<img src="${escapeAttribute(getUploadUrl(url))}" alt="${escapeAttribute(alt)}">
${canEdit ? `
<button type="button" class="remove-chapter-image" data-marker-index="${markerIndex}" data-image-index="${imageIndex}" aria-label="Remove image">
<i class="fas fa-times"></i>
</button>
` : ''}
</div>
`;
}).join('');
if (!imageHtml) return '';
return `<div class="chapter-images">${imageHtml}</div>`;
}
function getImageToolsHtml(markerIndex) {
return `
<div class="chapter-image-tools">
<input type="file" class="chapter-image-input" data-marker-index="${markerIndex}" accept="image/*" multiple>
</div>
`;
}
function attachChapterImageControls() {
document.querySelectorAll('.chapter-image-input').forEach(input => {
input.addEventListener('change', () => uploadChapterImages(input));
});
document.querySelectorAll('.remove-chapter-image').forEach(button => {
button.addEventListener('click', () => removeChapterImage(
parseInt(button.dataset.markerIndex),
parseInt(button.dataset.imageIndex)
));
});
}
async function uploadImages(files) {
if (!files.length) return [];
const formData = new FormData();
files.forEach(file => formData.append('images', file));
const res = await fetch(`${API_BASE}/uploads/images`, {
method: 'POST',
body: formData,
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Image upload failed');
return data.images || [];
}
async function uploadChapterImages(input) {
const markerIndex = parseInt(input.dataset.markerIndex);
const files = Array.from(input.files || []);
if (!files.length || !currentJourney.markers[markerIndex]) return;
const originalImages = [...getMarkerImages(currentJourney.markers[markerIndex])];
try {
showToast('Uploading images...');
const uploadedImages = await uploadImages(files);
const marker = currentJourney.markers[markerIndex];
marker.images = [...originalImages, ...uploadedImages];
await saveJourneyMarkers();
showToast('Images uploaded');
renderJourney();
} catch (err) {
currentJourney.markers[markerIndex].images = originalImages;
showToast(err.message || 'Error uploading images', true);
} finally {
input.value = '';
}
}
async function removeChapterImage(markerIndex, imageIndex) {
const marker = currentJourney.markers[markerIndex];
if (!marker) return;
const originalImages = [...getMarkerImages(marker)];
marker.images = getMarkerImages(marker).filter((_, idx) => idx !== imageIndex);
try {
await saveJourneyMarkers();
showToast('Image removed');
renderJourney();
} catch (err) {
marker.images = originalImages;
showToast('Error removing image', true);
}
}
async function saveJourneyMarkers() {
const res = await fetch(`${API_BASE}/journeys/${currentJourney.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ markers: currentJourney.markers }),
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Could not save images');
currentJourney = data;
}
function escapeAttribute(value) {
return String(value || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[char]);
}
async function deleteJourney() {
if (!confirm('Delete this journey permanently? All chapters and comments will be lost.')) return;
try {
const res = await fetch(`${API_BASE}/journeys/${currentJourney.id}`, {
method: 'DELETE',
credentials: 'include'
});
if (!res.ok) throw new Error('Delete failed');
showToast('Journey deleted');
setTimeout(() => window.location.href = 'blog-list.html', 1000);
} catch (err) {
showToast('Error deleting journey', true);
}
}
// ==================== COMMENTS ====================
async function loadComments() {
try {
const res = await fetch(`${API_BASE}/journeys/${journeyId}/comments`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to load comments');
const comments = await res.json();
renderComments(comments);
} catch (err) {
console.error(err);
}
}
function renderComments(comments) {
const container = document.getElementById('comments-section');
const isOwner = currentUser && currentUser.id === currentJourney.owner_id;
const canComment = canViewJourney();
if (!comments.length) {
container.innerHTML = `
<h3><i class="fas fa-comments"></i> Comments</h3>
<p class="empty-state">No comments yet. Be the first to comment!</p>
${canComment && currentUser ? getCommentFormHtml() : (!currentUser ? '<p><a href="login.html">Login</a> to comment.</p>' : '')}
`;
if (canComment && currentUser) attachCommentForm();
return;
}
let commentsHtml = '<h3><i class="fas fa-comments"></i> Comments</h3>';
comments.forEach(comment => {
const isOwnerOrAuthor = currentUser && (currentUser.id === comment.author_id || currentUser.id === currentJourney.owner_id);
commentsHtml += `
<div class="comment" data-comment-id="${comment.id}">
<div class="comment-meta">
<strong>${escapeHtml(comment.author_name)}</strong> ${new Date(comment.created_at).toLocaleString()}
${isOwnerOrAuthor ? `<button class="delete-comment" data-id="${comment.id}"><i class="fas fa-trash-alt"></i> Delete</button>` : ''}
</div>
<p class="comment-text">${escapeHtml(comment.text)}</p>
</div>
`;
});
container.innerHTML = commentsHtml + (canComment && currentUser ? getCommentFormHtml() : (canComment ? '<p><a href="login.html">Login</a> to comment.</p>' : ''));
document.querySelectorAll('.delete-comment').forEach(btn => {
btn.addEventListener('click', () => deleteComment(parseInt(btn.dataset.id)));
});
if (canComment && currentUser) attachCommentForm();
}
function getCommentFormHtml() {
return `
<div class="comment-form">
<textarea id="comment-text" rows="3" placeholder="Write a comment..."></textarea>
<button id="submit-comment" class="btn btn-sm" style="margin-top: var(--size-2);"><i class="fas fa-paper-plane"></i> Post Comment</button>
</div>
`;
}
function attachCommentForm() {
document.getElementById('submit-comment')?.addEventListener('click', submitComment);
}
async function submitComment() {
const text = document.getElementById('comment-text').value.trim();
if (!text) {
showToast('Comment cannot be empty', true);
return;
}
try {
const res = await fetch(`${API_BASE}/journeys/${journeyId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
credentials: 'include'
});
if (!res.ok) throw new Error('Failed to post comment');
showToast('Comment posted');
loadComments();
} catch (err) {
showToast('Error posting comment', true);
}
}
async function deleteComment(commentId) {
if (!confirm('Delete this comment?')) return;
try {
const res = await fetch(`${API_BASE}/comments/${commentId}`, {
method: 'DELETE',
credentials: 'include'
});
if (!res.ok) throw new Error('Delete failed');
showToast('Comment deleted');
loadComments();
} catch (err) {
showToast('Error deleting comment', true);
}
}
// ==================== INIT ====================
document.addEventListener('DOMContentLoaded', async () => {
const authenticated = await checkAuthAndRedirect();
if (!authenticated) return;
updateUserMenu();
loadJourney();
});
</script>
<script src="js/blog-post.js"></script>
</body>
</html>

View File

@ -381,398 +381,6 @@
<div id="toast" class="toast"></div>
<script src="js/auth.js"></script>
<script>
// ==================== GLOBALS ====================
let currentJourneyId = null;
let currentJourneyOwnerId = null;
let markersData = [];
let availableUsers = [];
let sharedReadIds = [];
let sharedEditIds = [];
const urlParams = new URLSearchParams(window.location.search);
const journeyId = urlParams.get('id');
const isNew = urlParams.has('new');
// ==================== UI HELPERS ====================
function showToast(message, isError = false) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.style.backgroundColor = isError ? 'var(--red-7)' : 'var(--green-7)';
toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 3000);
}
function getUploadUrl(path) {
if (!path) return '';
if (path.startsWith('http')) return path;
return API_BASE.replace('/api', path);
}
function escapeAttribute(value) {
return String(value || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[char]);
}
function normalizeMarkerImages(marker) {
const images = marker.images || (marker.image ? [marker.image] : []);
return { ...marker, images };
}
function canManageSharing() {
return !currentJourneyOwnerId || currentJourneyOwnerId === currentUser?.id;
}
function uniqueIds(ids) {
return [...new Set((ids || []).map((id) => parseInt(id)).filter(Number.isInteger))];
}
async function loadUsers() {
try {
const res = await fetch(`${API_BASE}/users`, { credentials: 'include' });
if (!res.ok) throw new Error('Could not load users');
availableUsers = await res.json();
} catch (err) {
availableUsers = [];
showToast('Sharing users could not be loaded', true);
}
}
function getUserCheckboxHtml(user, group, checked, disabled) {
return `
<label class="sharing-user">
<input type="checkbox" class="${group}-user" value="${user.id}" ${checked ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${escapeHtml(user.username)}</span>
</label>
`;
}
function renderSharingControls() {
const panel = document.getElementById('sharing-panel');
const visibilitySelect = document.getElementById('journey-visibility');
const visibility = visibilitySelect.value;
const readContainer = document.getElementById('shared-read-users');
const editContainer = document.getElementById('shared-edit-users');
const isShared = visibility === 'shared';
const disabled = !canManageSharing();
visibilitySelect.disabled = disabled;
panel.classList.toggle('visible', isShared);
if (!isShared) return;
if (!availableUsers.length) {
readContainer.innerHTML = '<p class="empty-share-list">No other users found.</p>';
editContainer.innerHTML = '<p class="empty-share-list">No other users found.</p>';
return;
}
readContainer.innerHTML = availableUsers.map((user) =>
getUserCheckboxHtml(user, 'shared-read', sharedReadIds.includes(user.id), disabled)
).join('');
editContainer.innerHTML = availableUsers.map((user) =>
getUserCheckboxHtml(user, 'shared-edit', sharedEditIds.includes(user.id), disabled)
).join('');
document.querySelectorAll('.shared-read-user').forEach((input) => {
input.addEventListener('change', updateSharedUsersFromForm);
});
document.querySelectorAll('.shared-edit-user').forEach((input) => {
input.addEventListener('change', updateSharedUsersFromForm);
});
}
function updateSharedUsersFromForm() {
sharedReadIds = uniqueIds([...document.querySelectorAll('.shared-read-user:checked')].map((input) => input.value));
sharedEditIds = uniqueIds([...document.querySelectorAll('.shared-edit-user:checked')].map((input) => input.value));
sharedReadIds = uniqueIds([...sharedReadIds, ...sharedEditIds]);
renderSharingControls();
}
function getSharingPayload(visibility) {
if (visibility !== 'shared') {
return { shared_read: [], shared_edit: [] };
}
return {
shared_read: uniqueIds([...sharedReadIds, ...sharedEditIds]),
shared_edit: uniqueIds(sharedEditIds)
};
}
function renderMarkerImages(marker, idx) {
const images = marker.images || [];
if (!images.length) {
return '';
}
return `
<div class="image-preview-grid">
${images.map((image, imageIdx) => {
const url = typeof image === 'string' ? image : image.url;
const alt = typeof image === 'string' ? 'Chapter image' : image.originalName || 'Chapter image';
if (!url) return '';
return `
<div class="image-preview">
<img src="${escapeAttribute(getUploadUrl(url))}" alt="${escapeAttribute(alt)}">
<button type="button" class="remove-image" data-index="${idx}" data-image-index="${imageIdx}" aria-label="Remove image">
<i class="fas fa-times"></i>
</button>
</div>
`;
}).join('')}
</div>
`;
}
async function uploadMarkerImages(files) {
if (!files.length) return [];
const formData = new FormData();
files.forEach((file) => formData.append('images', file));
const res = await fetch(`${API_BASE}/uploads/images`, {
method: 'POST',
body: formData,
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Image upload failed');
return data.images || [];
}
// ==================== RENDER MARKERS ====================
function renderMarkers() {
const container = document.getElementById('markers-container');
if (!markersData.length) {
container.innerHTML = '<p class="empty-state" style="text-align:center; color: var(--gray-6);">No chapters yet. Click "Add Chapter" to create one.</p>';
return;
}
container.innerHTML = markersData.map((marker, idx) => `
<div class="marker-card" data-marker-index="${idx}">
<div class="marker-header">
<h4><i class="fas fa-map-pin"></i> Chapter ${idx+1}</h4>
<button type="button" class="remove-marker" data-index="${idx}"><i class="fas fa-trash-alt"></i></button>
</div>
<div class="marker-coords">
<i class="fas fa-location-dot"></i> ${marker.lat.toFixed(6)}, ${marker.lng.toFixed(6)}
</div>
<div class="form-group">
<label>Chapter Title</label>
<input type="text" class="marker-title" data-index="${idx}" value="${escapeHtml(marker.title || '')}" placeholder="Title">
</div>
<div class="form-group">
<label>Date (optional)</label>
<input type="date" class="marker-date" data-index="${idx}" value="${marker.date || ''}">
</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>
</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('');
// Attach remove listeners
document.querySelectorAll('.remove-marker').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt(btn.dataset.index);
markersData.splice(idx, 1);
renderMarkers();
});
});
// Attach input listeners to update markersData
document.querySelectorAll('.marker-title').forEach(input => {
input.addEventListener('input', (e) => {
const idx = parseInt(input.dataset.index);
markersData[idx].title = input.value;
});
});
document.querySelectorAll('.marker-date').forEach(input => {
input.addEventListener('input', (e) => {
const idx = parseInt(input.dataset.index);
markersData[idx].date = input.value;
});
});
document.querySelectorAll('.marker-description').forEach(textarea => {
textarea.addEventListener('input', (e) => {
const idx = parseInt(textarea.dataset.index);
markersData[idx].description = textarea.value;
});
});
document.querySelectorAll('.marker-images').forEach(input => {
input.addEventListener('change', async () => {
const idx = parseInt(input.dataset.index);
try {
const uploadedImages = await uploadMarkerImages(Array.from(input.files));
markersData[idx].images = [...(markersData[idx].images || []), ...uploadedImages];
showToast('Image uploaded');
renderMarkers();
} catch (err) {
showToast(err.message, true);
}
});
});
document.querySelectorAll('.remove-image').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index);
const imageIdx = parseInt(btn.dataset.imageIndex);
markersData[idx].images = (markersData[idx].images || []).filter((_, i) => i !== imageIdx);
renderMarkers();
});
});
document.querySelectorAll('.marker-video').forEach(input => {
input.addEventListener('input', (e) => {
const idx = parseInt(input.dataset.index);
markersData[idx].videoUrl = input.value;
});
});
}
// ==================== LOAD JOURNEY ====================
async function loadJourney(id) {
console.log('Loading journey with id:', id);
try {
const res = await fetch(`${API_BASE}/journeys/${id}`, { credentials: 'include' });
if (!res.ok) {
console.error('Failed to fetch journey, status:', res.status);
throw new Error('Journey not found');
}
const journey = await res.json();
console.log('Journey data:', journey);
currentJourneyId = journey.id;
currentJourneyOwnerId = journey.owner_id;
document.getElementById('journey-title').value = journey.title;
document.getElementById('journey-description').value = journey.description || '';
document.getElementById('journey-visibility').value = journey.visibility || 'private';
sharedReadIds = uniqueIds(journey.shared_read || []);
sharedEditIds = uniqueIds(journey.shared_edit || []);
markersData = (journey.markers || []).map(normalizeMarkerImages);
console.log('Markers data loaded:', markersData);
renderSharingControls();
renderMarkers();
document.getElementById('form-title').textContent = 'Edit Journey';
} catch (err) {
console.error('Error loading journey:', err);
showToast('Error loading journey: ' + err.message, true);
// Optionally redirect after a delay
setTimeout(() => window.location.href = 'blog-list.html', 2000);
}
}
// ==================== SAVE JOURNEY ====================
async function saveJourney(event) {
event.preventDefault();
const title = document.getElementById('journey-title').value.trim();
const description = document.getElementById('journey-description').value.trim();
const visibility = document.getElementById('journey-visibility').value;
if (!title) {
showToast('Please enter a title', true);
return;
}
// Build markers array from current form data (already in markersData)
const markers = markersData.map(m => ({
lat: m.lat,
lng: m.lng,
title: m.title || '',
date: m.date || '',
description: m.description || '',
images: m.images || [],
videoUrl: m.videoUrl || ''
}));
const payload = { title, description, markers };
if (canManageSharing()) {
payload.visibility = visibility;
Object.assign(payload, getSharingPayload(visibility));
}
console.log('Saving payload:', payload);
try {
let res;
if (isNew) {
res = await fetch(`${API_BASE}/journeys`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include'
});
} else {
res = await fetch(`${API_BASE}/journeys/${currentJourneyId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include'
});
}
if (!res.ok) throw new Error('Save failed');
const data = await res.json();
console.log('Save response:', data);
showToast('Journey saved!');
// Redirect to read view
window.location.href = `blog-post.html?id=${data.id}`;
} catch (err) {
console.error('Error saving journey:', err);
showToast('Error saving journey: ' + err.message, true);
}
}
// ==================== ADD MARKER ====================
function addMarker() {
console.log('Adding new marker');
markersData.push({
lat: 46.8182,
lng: 8.2275,
title: 'New Chapter',
date: '',
description: '',
images: [],
videoUrl: ''
});
renderMarkers();
}
// ==================== INITIALIZATION ====================
document.addEventListener('DOMContentLoaded', async () => {
const authenticated = await checkAuthAndRedirect();
if (!authenticated) return;
updateUserMenu();
if (!isNew && journeyId) {
await loadUsers();
await loadJourney(journeyId);
} else if (isNew) {
currentJourneyId = null;
currentJourneyOwnerId = currentUser.id;
document.getElementById('form-title').textContent = 'New Journey';
markersData = [];
sharedReadIds = [];
sharedEditIds = [];
await loadUsers();
renderSharingControls();
renderMarkers();
} else {
window.location.href = 'blog-list.html';
}
document.getElementById('journey-form').addEventListener('submit', saveJourney);
document.getElementById('add-marker').addEventListener('click', addMarker);
document.getElementById('journey-visibility').addEventListener('change', renderSharingControls);
});
</script>
<script src="js/journey-edit.js"></script>
</body>
</html>

81
js/blog-list.js Normal file
View File

@ -0,0 +1,81 @@
let allJourneys = [];
// ==================== LOAD JOURNEYS ====================
async function loadJourneys() {
try {
const res = await fetch(`${API_BASE}/journeys`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to fetch journeys');
const journeys = await res.json();
allJourneys = journeys;
renderJourneys(journeys);
} catch (err) {
console.error(err);
document.getElementById('posts-grid').innerHTML = '<p class="empty-state">Failed to load journeys. Make sure the backend is running.</p>';
}
}
function renderJourneys(journeys) {
const container = document.getElementById('posts-grid');
if (!journeys.length) {
container.innerHTML = `
<div class="empty-state">
<p>No journeys yet. Create one on the map!</p>
</div>
`;
return;
}
container.innerHTML = journeys.map(journey => `
<article class="post-card">
${journey.image ? `<img class="post-card-image" src="${journey.image}" alt="${journey.title}">` : '<div class="post-card-image" style="background: var(--surface-3); display: flex; align-items: center; justify-content: center;"><i class="fas fa-image" style="font-size: 3rem; color: var(--gray-5);"></i></div>'}
<div class="post-card-content">
<h2 class="post-card-title"><a href="blog-post.html?id=${journey.id}">${escapeHtml(journey.title)}</a></h2>
<div class="post-card-meta">
<i class="fas fa-calendar-alt"></i> ${new Date(journey.created_at).toLocaleDateString()}
${journey.markers ? `<span style="margin-left: 12px;"><i class="fas fa-map-marker-alt"></i> ${journey.markers.length} chapters</span>` : ''}
${getJourneyBadges(journey)}
</div>
<div class="post-card-excerpt">${escapeHtml(journey.description || journey.markers?.[0]?.text?.substring(0, 150) + '…')}</div>
</div>
</article>
`).join('');
}
function getJourneyBadges(journey) {
const badges = [];
const isOwner = journey.owner_id === currentUser?.id;
const isShared = journey.visibility === 'shared' && !isOwner;
if (journey.visibility === 'public') badges.push('Public');
if (isShared) badges.push(journey.can_edit ? 'Shared edit' : 'Shared');
return badges.map((badge) => `<span class="badge">${badge}</span>`).join('');
}
function filterJourneys(filter) {
if (filter === 'all') {
renderJourneys(allJourneys);
} else if (filter === 'own') {
renderJourneys(allJourneys.filter(j => j.owner_id === currentUser?.id));
} else if (filter === 'shared') {
renderJourneys(allJourneys.filter(j => j.owner_id !== currentUser?.id && j.visibility === 'shared'));
} else if (filter === 'public') {
renderJourneys(allJourneys.filter(j => j.visibility === 'public'));
}
}
// ==================== INITIALIZATION ====================
document.addEventListener('DOMContentLoaded', async () => {
const authenticated = await checkAuthAndRedirect();
if (!authenticated) return;
updateUserMenu();
loadJourneys();
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
filterJourneys(btn.dataset.filter);
});
});
});

351
js/blog-post.js Normal file
View File

@ -0,0 +1,351 @@
let currentJourney = null;
const urlParams = new URLSearchParams(window.location.search);
const journeyId = urlParams.get('id');
// ==================== LOAD JOURNEY ====================
async function loadJourney() {
if (!journeyId) {
window.location.href = 'blog-list.html';
return;
}
try {
const res = await fetch(`${API_BASE}/journeys/${journeyId}`, { credentials: 'include' });
if (!res.ok) throw new Error('Journey not found');
currentJourney = await res.json();
renderJourney();
loadComments();
} catch (err) {
showToast('Error loading journey', true);
setTimeout(() => window.location.href = 'blog-list.html', 2000);
}
}
function renderJourney() {
const container = document.getElementById('post-content');
const isOwner = currentUser && currentUser.id === currentJourney.owner_id;
const canEdit = canEditJourney();
// Build chapters from markers
let chaptersHtml = '';
if (currentJourney.markers && currentJourney.markers.length) {
chaptersHtml = '<div class="chapters">';
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>') : '';
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">
<h3>${escapeHtml(title)}</h3>
${date}
</div>
${images}
${canEdit ? getImageToolsHtml(idx) : ''}
${video}
<div class="chapter-content">${content}</div>
</div>
`;
});
chaptersHtml += '</div>';
} else {
chaptersHtml = '<p>No chapters yet.</p>';
}
container.innerHTML = `
<article class="post-card">
<h1 class="post-title">${escapeHtml(currentJourney.title)}</h1>
<div class="post-meta">
<i class="fas fa-calendar-alt"></i> ${new Date(currentJourney.created_at).toLocaleDateString()}
${currentJourney.visibility === 'public' ? '<span class="badge">Public</span>' : ''}
${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>
${chaptersHtml}
${canEdit || isOwner ? `
<div style="margin-top: var(--size-4); display: flex; gap: var(--size-2);">
${canEdit ? `<a href="journey-edit.html?id=${currentJourney.id}" class="btn btn-sm"><i class="fas fa-edit"></i> Edit</a>` : ''}
${isOwner ? `<button id="delete-journey-btn" class="btn btn-danger btn-sm"><i class="fas fa-trash"></i> Delete</button>` : ''}
</div>
` : ''}
</article>
`;
if (isOwner) {
document.getElementById('delete-journey-btn')?.addEventListener('click', deleteJourney);
}
if (canEdit) {
attachChapterImageControls();
}
}
function getUploadUrl(path) {
if (!path) return '';
if (path.startsWith('http')) return path;
return API_BASE.replace('/api', path);
}
function canEditJourney() {
if (!currentUser || !currentJourney) return false;
if (currentUser.id === currentJourney.owner_id) return true;
return currentJourney.visibility === 'shared'
&& Array.isArray(currentJourney.shared_edit)
&& currentJourney.shared_edit.includes(currentUser.id);
}
function canViewJourney() {
if (!currentUser || !currentJourney) return false;
if (currentUser.id === currentJourney.owner_id) return true;
if (currentJourney.visibility === 'public') return true;
return currentJourney.visibility === 'shared'
&& (
(Array.isArray(currentJourney.shared_read) && currentJourney.shared_read.includes(currentUser.id))
|| (Array.isArray(currentJourney.shared_edit) && currentJourney.shared_edit.includes(currentUser.id))
);
}
function getMarkerImages(marker) {
return marker.images || (marker.image ? [marker.image] : []);
}
function getMarkerImagesHtml(images, markerIndex, canEdit) {
if (!images || !images.length) return '';
const imageHtml = images.map((image, imageIndex) => {
const url = typeof image === 'string' ? image : image.url;
const alt = typeof image === 'string' ? 'Journey image' : image.originalName || 'Journey image';
if (!url) return '';
return `
<div class="chapter-image-item">
<img src="${escapeAttribute(getUploadUrl(url))}" alt="${escapeAttribute(alt)}">
${canEdit ? `
<button type="button" class="remove-chapter-image" data-marker-index="${markerIndex}" data-image-index="${imageIndex}" aria-label="Remove image">
<i class="fas fa-times"></i>
</button>
` : ''}
</div>
`;
}).join('');
if (!imageHtml) return '';
return `<div class="chapter-images">${imageHtml}</div>`;
}
function getImageToolsHtml(markerIndex) {
return `
<div class="chapter-image-tools">
<input type="file" class="chapter-image-input" data-marker-index="${markerIndex}" accept="image/*" multiple>
</div>
`;
}
function attachChapterImageControls() {
document.querySelectorAll('.chapter-image-input').forEach(input => {
input.addEventListener('change', () => uploadChapterImages(input));
});
document.querySelectorAll('.remove-chapter-image').forEach(button => {
button.addEventListener('click', () => removeChapterImage(
parseInt(button.dataset.markerIndex),
parseInt(button.dataset.imageIndex)
));
});
}
async function uploadImages(files) {
if (!files.length) return [];
const formData = new FormData();
files.forEach(file => formData.append('images', file));
const res = await fetch(`${API_BASE}/uploads/images`, {
method: 'POST',
body: formData,
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Image upload failed');
return data.images || [];
}
async function uploadChapterImages(input) {
const markerIndex = parseInt(input.dataset.markerIndex);
const files = Array.from(input.files || []);
if (!files.length || !currentJourney.markers[markerIndex]) return;
const originalImages = [...getMarkerImages(currentJourney.markers[markerIndex])];
try {
showToast('Uploading images...');
const uploadedImages = await uploadImages(files);
const marker = currentJourney.markers[markerIndex];
marker.images = [...originalImages, ...uploadedImages];
await saveJourneyMarkers();
showToast('Images uploaded');
renderJourney();
} catch (err) {
currentJourney.markers[markerIndex].images = originalImages;
showToast(err.message || 'Error uploading images', true);
} finally {
input.value = '';
}
}
async function removeChapterImage(markerIndex, imageIndex) {
const marker = currentJourney.markers[markerIndex];
if (!marker) return;
const originalImages = [...getMarkerImages(marker)];
marker.images = getMarkerImages(marker).filter((_, idx) => idx !== imageIndex);
try {
await saveJourneyMarkers();
showToast('Image removed');
renderJourney();
} catch (err) {
marker.images = originalImages;
showToast('Error removing image', true);
}
}
async function saveJourneyMarkers() {
const res = await fetch(`${API_BASE}/journeys/${currentJourney.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ markers: currentJourney.markers }),
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Could not save images');
currentJourney = data;
}
function escapeAttribute(value) {
return String(value || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[char]);
}
async function deleteJourney() {
if (!confirm('Delete this journey permanently? All chapters and comments will be lost.')) return;
try {
const res = await fetch(`${API_BASE}/journeys/${currentJourney.id}`, {
method: 'DELETE',
credentials: 'include'
});
if (!res.ok) throw new Error('Delete failed');
showToast('Journey deleted');
setTimeout(() => window.location.href = 'blog-list.html', 1000);
} catch (err) {
showToast('Error deleting journey', true);
}
}
// ==================== COMMENTS ====================
async function loadComments() {
try {
const res = await fetch(`${API_BASE}/journeys/${journeyId}/comments`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to load comments');
const comments = await res.json();
renderComments(comments);
} catch (err) {
console.error(err);
}
}
function renderComments(comments) {
const container = document.getElementById('comments-section');
const isOwner = currentUser && currentUser.id === currentJourney.owner_id;
const canComment = canViewJourney();
if (!comments.length) {
container.innerHTML = `
<h3><i class="fas fa-comments"></i> Comments</h3>
<p class="empty-state">No comments yet. Be the first to comment!</p>
${canComment && currentUser ? getCommentFormHtml() : (!currentUser ? '<p><a href="login.html">Login</a> to comment.</p>' : '')}
`;
if (canComment && currentUser) attachCommentForm();
return;
}
let commentsHtml = '<h3><i class="fas fa-comments"></i> Comments</h3>';
comments.forEach(comment => {
const isOwnerOrAuthor = currentUser && (currentUser.id === comment.author_id || currentUser.id === currentJourney.owner_id);
commentsHtml += `
<div class="comment" data-comment-id="${comment.id}">
<div class="comment-meta">
<strong>${escapeHtml(comment.author_name)}</strong> ${new Date(comment.created_at).toLocaleString()}
${isOwnerOrAuthor ? `<button class="delete-comment" data-id="${comment.id}"><i class="fas fa-trash-alt"></i> Delete</button>` : ''}
</div>
<p class="comment-text">${escapeHtml(comment.text)}</p>
</div>
`;
});
container.innerHTML = commentsHtml + (canComment && currentUser ? getCommentFormHtml() : (canComment ? '<p><a href="login.html">Login</a> to comment.</p>' : ''));
document.querySelectorAll('.delete-comment').forEach(btn => {
btn.addEventListener('click', () => deleteComment(parseInt(btn.dataset.id)));
});
if (canComment && currentUser) attachCommentForm();
}
function getCommentFormHtml() {
return `
<div class="comment-form">
<textarea id="comment-text" rows="3" placeholder="Write a comment..."></textarea>
<button id="submit-comment" class="btn btn-sm" style="margin-top: var(--size-2);"><i class="fas fa-paper-plane"></i> Post Comment</button>
</div>
`;
}
function attachCommentForm() {
document.getElementById('submit-comment')?.addEventListener('click', submitComment);
}
async function submitComment() {
const text = document.getElementById('comment-text').value.trim();
if (!text) {
showToast('Comment cannot be empty', true);
return;
}
try {
const res = await fetch(`${API_BASE}/journeys/${journeyId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
credentials: 'include'
});
if (!res.ok) throw new Error('Failed to post comment');
showToast('Comment posted');
loadComments();
} catch (err) {
showToast('Error posting comment', true);
}
}
async function deleteComment(commentId) {
if (!confirm('Delete this comment?')) return;
try {
const res = await fetch(`${API_BASE}/comments/${commentId}`, {
method: 'DELETE',
credentials: 'include'
});
if (!res.ok) throw new Error('Delete failed');
showToast('Comment deleted');
loadComments();
} catch (err) {
showToast('Error deleting comment', true);
}
}
// ==================== INIT ====================
document.addEventListener('DOMContentLoaded', async () => {
const authenticated = await checkAuthAndRedirect();
if (!authenticated) return;
updateUserMenu();
loadJourney();
});

391
js/journey-edit.js Normal file
View File

@ -0,0 +1,391 @@
// ==================== GLOBALS ====================
let currentJourneyId = null;
let currentJourneyOwnerId = null;
let markersData = [];
let availableUsers = [];
let sharedReadIds = [];
let sharedEditIds = [];
const urlParams = new URLSearchParams(window.location.search);
const journeyId = urlParams.get('id');
const isNew = urlParams.has('new');
// ==================== UI HELPERS ====================
function showToast(message, isError = false) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.style.backgroundColor = isError ? 'var(--red-7)' : 'var(--green-7)';
toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 3000);
}
function getUploadUrl(path) {
if (!path) return '';
if (path.startsWith('http')) return path;
return API_BASE.replace('/api', path);
}
function escapeAttribute(value) {
return String(value || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[char]);
}
function normalizeMarkerImages(marker) {
const images = marker.images || (marker.image ? [marker.image] : []);
return { ...marker, images };
}
function canManageSharing() {
return !currentJourneyOwnerId || currentJourneyOwnerId === currentUser?.id;
}
function uniqueIds(ids) {
return [...new Set((ids || []).map((id) => parseInt(id)).filter(Number.isInteger))];
}
async function loadUsers() {
try {
const res = await fetch(`${API_BASE}/users`, { credentials: 'include' });
if (!res.ok) throw new Error('Could not load users');
availableUsers = await res.json();
} catch (err) {
availableUsers = [];
showToast('Sharing users could not be loaded', true);
}
}
function getUserCheckboxHtml(user, group, checked, disabled) {
return `
<label class="sharing-user">
<input type="checkbox" class="${group}-user" value="${user.id}" ${checked ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${escapeHtml(user.username)}</span>
</label>
`;
}
function renderSharingControls() {
const panel = document.getElementById('sharing-panel');
const visibilitySelect = document.getElementById('journey-visibility');
const visibility = visibilitySelect.value;
const readContainer = document.getElementById('shared-read-users');
const editContainer = document.getElementById('shared-edit-users');
const isShared = visibility === 'shared';
const disabled = !canManageSharing();
visibilitySelect.disabled = disabled;
panel.classList.toggle('visible', isShared);
if (!isShared) return;
if (!availableUsers.length) {
readContainer.innerHTML = '<p class="empty-share-list">No other users found.</p>';
editContainer.innerHTML = '<p class="empty-share-list">No other users found.</p>';
return;
}
readContainer.innerHTML = availableUsers.map((user) =>
getUserCheckboxHtml(user, 'shared-read', sharedReadIds.includes(user.id), disabled)
).join('');
editContainer.innerHTML = availableUsers.map((user) =>
getUserCheckboxHtml(user, 'shared-edit', sharedEditIds.includes(user.id), disabled)
).join('');
document.querySelectorAll('.shared-read-user').forEach((input) => {
input.addEventListener('change', updateSharedUsersFromForm);
});
document.querySelectorAll('.shared-edit-user').forEach((input) => {
input.addEventListener('change', updateSharedUsersFromForm);
});
}
function updateSharedUsersFromForm() {
sharedReadIds = uniqueIds([...document.querySelectorAll('.shared-read-user:checked')].map((input) => input.value));
sharedEditIds = uniqueIds([...document.querySelectorAll('.shared-edit-user:checked')].map((input) => input.value));
sharedReadIds = uniqueIds([...sharedReadIds, ...sharedEditIds]);
renderSharingControls();
}
function getSharingPayload(visibility) {
if (visibility !== 'shared') {
return { shared_read: [], shared_edit: [] };
}
return {
shared_read: uniqueIds([...sharedReadIds, ...sharedEditIds]),
shared_edit: uniqueIds(sharedEditIds)
};
}
function renderMarkerImages(marker, idx) {
const images = marker.images || [];
if (!images.length) {
return '';
}
return `
<div class="image-preview-grid">
${images.map((image, imageIdx) => {
const url = typeof image === 'string' ? image : image.url;
const alt = typeof image === 'string' ? 'Chapter image' : image.originalName || 'Chapter image';
if (!url) return '';
return `
<div class="image-preview">
<img src="${escapeAttribute(getUploadUrl(url))}" alt="${escapeAttribute(alt)}">
<button type="button" class="remove-image" data-index="${idx}" data-image-index="${imageIdx}" aria-label="Remove image">
<i class="fas fa-times"></i>
</button>
</div>
`;
}).join('')}
</div>
`;
}
async function uploadMarkerImages(files) {
if (!files.length) return [];
const formData = new FormData();
files.forEach((file) => formData.append('images', file));
const res = await fetch(`${API_BASE}/uploads/images`, {
method: 'POST',
body: formData,
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Image upload failed');
return data.images || [];
}
// ==================== RENDER MARKERS ====================
function renderMarkers() {
const container = document.getElementById('markers-container');
if (!markersData.length) {
container.innerHTML = '<p class="empty-state" style="text-align:center; color: var(--gray-6);">No chapters yet. Click "Add Chapter" to create one.</p>';
return;
}
container.innerHTML = markersData.map((marker, idx) => `
<div class="marker-card" data-marker-index="${idx}">
<div class="marker-header">
<h4><i class="fas fa-map-pin"></i> Chapter ${idx+1}</h4>
<button type="button" class="remove-marker" data-index="${idx}"><i class="fas fa-trash-alt"></i></button>
</div>
<div class="marker-coords">
<i class="fas fa-location-dot"></i> ${marker.lat.toFixed(6)}, ${marker.lng.toFixed(6)}
</div>
<div class="form-group">
<label>Chapter Title</label>
<input type="text" class="marker-title" data-index="${idx}" value="${escapeHtml(marker.title || '')}" placeholder="Title">
</div>
<div class="form-group">
<label>Date (optional)</label>
<input type="date" class="marker-date" data-index="${idx}" value="${marker.date || ''}">
</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>
</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('');
// Attach remove listeners
document.querySelectorAll('.remove-marker').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt(btn.dataset.index);
markersData.splice(idx, 1);
renderMarkers();
});
});
// Attach input listeners to update markersData
document.querySelectorAll('.marker-title').forEach(input => {
input.addEventListener('input', (e) => {
const idx = parseInt(input.dataset.index);
markersData[idx].title = input.value;
});
});
document.querySelectorAll('.marker-date').forEach(input => {
input.addEventListener('input', (e) => {
const idx = parseInt(input.dataset.index);
markersData[idx].date = input.value;
});
});
document.querySelectorAll('.marker-description').forEach(textarea => {
textarea.addEventListener('input', (e) => {
const idx = parseInt(textarea.dataset.index);
markersData[idx].description = textarea.value;
});
});
document.querySelectorAll('.marker-images').forEach(input => {
input.addEventListener('change', async () => {
const idx = parseInt(input.dataset.index);
try {
const uploadedImages = await uploadMarkerImages(Array.from(input.files));
markersData[idx].images = [...(markersData[idx].images || []), ...uploadedImages];
showToast('Image uploaded');
renderMarkers();
} catch (err) {
showToast(err.message, true);
}
});
});
document.querySelectorAll('.remove-image').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index);
const imageIdx = parseInt(btn.dataset.imageIndex);
markersData[idx].images = (markersData[idx].images || []).filter((_, i) => i !== imageIdx);
renderMarkers();
});
});
document.querySelectorAll('.marker-video').forEach(input => {
input.addEventListener('input', (e) => {
const idx = parseInt(input.dataset.index);
markersData[idx].videoUrl = input.value;
});
});
}
// ==================== LOAD JOURNEY ====================
async function loadJourney(id) {
console.log('Loading journey with id:', id);
try {
const res = await fetch(`${API_BASE}/journeys/${id}`, { credentials: 'include' });
if (!res.ok) {
console.error('Failed to fetch journey, status:', res.status);
throw new Error('Journey not found');
}
const journey = await res.json();
console.log('Journey data:', journey);
currentJourneyId = journey.id;
currentJourneyOwnerId = journey.owner_id;
document.getElementById('journey-title').value = journey.title;
document.getElementById('journey-description').value = journey.description || '';
document.getElementById('journey-visibility').value = journey.visibility || 'private';
sharedReadIds = uniqueIds(journey.shared_read || []);
sharedEditIds = uniqueIds(journey.shared_edit || []);
markersData = (journey.markers || []).map(normalizeMarkerImages);
console.log('Markers data loaded:', markersData);
renderSharingControls();
renderMarkers();
document.getElementById('form-title').textContent = 'Edit Journey';
} catch (err) {
console.error('Error loading journey:', err);
showToast('Error loading journey: ' + err.message, true);
// Optionally redirect after a delay
setTimeout(() => window.location.href = 'blog-list.html', 2000);
}
}
// ==================== SAVE JOURNEY ====================
async function saveJourney(event) {
event.preventDefault();
const title = document.getElementById('journey-title').value.trim();
const description = document.getElementById('journey-description').value.trim();
const visibility = document.getElementById('journey-visibility').value;
if (!title) {
showToast('Please enter a title', true);
return;
}
// Build markers array from current form data (already in markersData)
const markers = markersData.map(m => ({
lat: m.lat,
lng: m.lng,
title: m.title || '',
date: m.date || '',
description: m.description || '',
images: m.images || [],
videoUrl: m.videoUrl || ''
}));
const payload = { title, description, markers };
if (canManageSharing()) {
payload.visibility = visibility;
Object.assign(payload, getSharingPayload(visibility));
}
console.log('Saving payload:', payload);
try {
let res;
if (isNew) {
res = await fetch(`${API_BASE}/journeys`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include'
});
} else {
res = await fetch(`${API_BASE}/journeys/${currentJourneyId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include'
});
}
if (!res.ok) throw new Error('Save failed');
const data = await res.json();
console.log('Save response:', data);
showToast('Journey saved!');
// Redirect to read view
window.location.href = `blog-post.html?id=${data.id}`;
} catch (err) {
console.error('Error saving journey:', err);
showToast('Error saving journey: ' + err.message, true);
}
}
// ==================== ADD MARKER ====================
function addMarker() {
console.log('Adding new marker');
markersData.push({
lat: 46.8182,
lng: 8.2275,
title: 'New Chapter',
date: '',
description: '',
images: [],
videoUrl: ''
});
renderMarkers();
}
// ==================== INITIALIZATION ====================
document.addEventListener('DOMContentLoaded', async () => {
const authenticated = await checkAuthAndRedirect();
if (!authenticated) return;
updateUserMenu();
if (!isNew && journeyId) {
await loadUsers();
await loadJourney(journeyId);
} else if (isNew) {
currentJourneyId = null;
currentJourneyOwnerId = currentUser.id;
document.getElementById('form-title').textContent = 'New Journey';
markersData = [];
sharedReadIds = [];
sharedEditIds = [];
await loadUsers();
renderSharingControls();
renderMarkers();
} else {
window.location.href = 'blog-list.html';
}
document.getElementById('journey-form').addEventListener('submit', saveJourney);
document.getElementById('add-marker').addEventListener('click', addMarker);
document.getElementById('journey-visibility').addEventListener('change', renderSharingControls);
});

62
js/login.js Normal file
View File

@ -0,0 +1,62 @@
document.addEventListener('DOMContentLoaded', () => {
// Tab switching
document.querySelectorAll('.auth-tab').forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.tab;
document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('.auth-form').forEach(f => f.classList.remove('active'));
document.getElementById(`${target}-form`).classList.add('active');
});
});
// Login button
document.getElementById('login-submit').addEventListener('click', async () => {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
if (!username || !password) {
showToast('Please enter username and password', true);
return;
}
try {
const res = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
showToast(`Welcome, ${data.username}`);
window.location.href = 'map-page.html';
} catch (err) {
showToast(err.message, true);
}
});
// Register button
document.getElementById('register-submit').addEventListener('click', async () => {
const username = document.getElementById('register-username').value.trim();
const password = document.getElementById('register-password').value;
if (!username || !password) {
showToast('Please enter username and password', true);
return;
}
if (password.length < 4) {
showToast('Password must be at least 4 characters', true);
return;
}
try {
const res = await fetch(`${API_BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Registration failed');
showToast(`Registered as ${data.username}. Logging in...`);
window.location.href = 'map-page.html';
} catch (err) {
showToast(err.message, true);
}
});
});

707
js/map-page.js Normal file
View File

@ -0,0 +1,707 @@
// ==================== MAP CODE ====================
(function () {
// ==================== STATE =====================
let map;
let currentJourney = {
id: null,
title: "",
description: "",
markers: [],
};
let availableJourneys = [];
let journeyPath;
let activeMarker = null;
// ==================== MAP INIT ==================
function initMap() {
map = L.map("map", {
worldCopyJump: true,
}).setView([46.8182, 8.2275], 8);
L.tileLayer(
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
).addTo(map);
journeyPath = L.polyline([], {
color: "#4263eb",
weight: 4,
}).addTo(map);
map.on("click", function (e) {
if (
document
.getElementById("mode-create")
.classList.contains("active")
) {
createMarker(normalizeLatLng(e.latlng), { title: "New Marker" });
}
});
setTimeout(() => map.invalidateSize(), 300);
}
// ==================== MARKER FUNCTIONS ==========
function normalizeLatLng(latlng) {
return L.latLng(
latlng.lat,
((((latlng.lng + 180) % 360) + 360) % 360) - 180,
);
}
function createMarker(latlng, content = {}) {
latlng = normalizeLatLng(latlng);
const marker = L.marker(latlng, {
draggable: true,
title: content.title || "Untitled",
}).addTo(map);
marker.bindPopup(
`<strong>${content.title || "Untitled"}</strong>`,
);
marker.on("click", () => openMarkerEditor(marker));
marker.on("dragend", () => {
marker.setLatLng(normalizeLatLng(marker.getLatLng()));
updateJourneyPath();
});
marker._content = {
...content,
lat: latlng.lat,
lng: latlng.lng,
};
currentJourney.markers.push(marker);
updateJourneyPath();
addMarkerToList(marker, latlng, content);
return marker;
}
function addMarkerToList(marker, latlng, content) {
const container = document.getElementById(
"current-markers-container",
);
const empty = container.querySelector(".empty-message");
if (empty) empty.remove();
const div = document.createElement("div");
div.className = "marker-item";
div.dataset.lat = latlng.lat;
div.dataset.lng = latlng.lng;
div.innerHTML = `
<div class="marker-title">${content.title || "Untitled"}</div>
<div class="marker-coords">${latlng.lat.toFixed(4)}, ${latlng.lng.toFixed(4)}</div>
`;
div.addEventListener("click", () => {
map.flyTo(latlng, 12);
openMarkerEditor(marker);
});
container.appendChild(div);
}
function updateJourneyPath() {
const coords = currentJourney.markers.map((m) => [
m.getLatLng().lat,
m.getLatLng().lng,
]);
journeyPath.setLatLngs(coords);
}
function getUploadUrl(path) {
if (!path) return "";
if (path.startsWith("http")) return path;
return API_BASE.replace("/api", path);
}
function renderMarkerImages(images = []) {
const preview = document.getElementById("marker-image-preview");
preview.innerHTML = "";
images.forEach((image, index) => {
const item = document.createElement("div");
item.className = "image-preview";
item.innerHTML = `
<img src="${getUploadUrl(image.url)}" alt="${image.originalName || "Marker image"}">
<button type="button" class="remove-image-btn" data-index="${index}" aria-label="Remove image">
<i class="fas fa-times"></i>
</button>
`;
item.querySelector(".remove-image-btn").addEventListener("click", () => {
if (!activeMarker) return;
const content = activeMarker._content || {};
content.images = (content.images || []).filter((_, i) => i !== index);
activeMarker._content = content;
renderMarkerImages(content.images);
});
preview.appendChild(item);
});
}
async function uploadMarkerImages(files) {
if (!files.length) return [];
const formData = new FormData();
files.forEach((file) => formData.append("images", file));
const res = await fetch(`${API_BASE}/uploads/images`, {
method: "POST",
body: formData,
credentials: "include",
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Image upload failed");
return data.images || [];
}
// ==================== MODAL =====================
function openMarkerEditor(marker) {
activeMarker = marker;
const latlng = marker.getLatLng();
const content = marker._content || {};
document.getElementById("marker-title").value =
content.title || "";
document.getElementById("marker-date").value =
content.date || "";
document.getElementById("marker-text").value =
content.description || "";
document.getElementById("video-url").value =
content.videoUrl || "";
document.getElementById("marker-images").value = "";
renderMarkerImages(content.images || []);
document.getElementById("marker-coords").value =
`${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`;
document
.getElementById("marker-modal")
.classList.add("active");
}
function closeModal() {
document
.getElementById("marker-modal")
.classList.remove("active");
activeMarker = null;
}
async function saveMarker() {
if (!activeMarker) return;
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;
try {
const uploadedImages = await uploadMarkerImages(Array.from(imageInput.files));
images = [...existingImages, ...uploadedImages];
} catch (err) {
showToast(err.message, "error");
return;
}
// Update marker's tooltip title and popup
activeMarker.options.title = title;
activeMarker.setPopupContent(`<strong>${title}</strong>`);
// Store content for saving
activeMarker._content = { title, date, description, videoUrl, images };
// Update the list item in the sidebar
const latlng = activeMarker.getLatLng();
const items = document.querySelectorAll("#current-markers-container .marker-item");
for (let item of items) {
if (item.dataset.lat == latlng.lat && item.dataset.lng == latlng.lng) {
item.querySelector(".marker-title").textContent = title;
break;
}
}
closeModal();
showToast("Saving journey...");
await saveJourneyToAPI();
}
function deleteMarkerFromMap() {
if (!activeMarker) return;
const latlng = activeMarker.getLatLng();
map.removeLayer(activeMarker);
const idx = currentJourney.markers.indexOf(activeMarker);
if (idx > -1) currentJourney.markers.splice(idx, 1);
const items = document.querySelectorAll(
"#current-markers-container .marker-item",
);
for (let item of items) {
if (
item.dataset.lat == latlng.lat &&
item.dataset.lng == latlng.lng
) {
item.remove();
break;
}
}
if (currentJourney.markers.length === 0) {
document.getElementById(
"current-markers-container",
).innerHTML =
'<p class="empty-message">No markers yet. Click on the map to add markers.</p>';
}
updateJourneyPath();
closeModal();
showToast("Marker deleted");
}
// ==================== API INTERACTION (with credentials) ====================
async function fetchJourneys() {
try {
const res = await fetch(`${API_BASE}/journeys`, {
credentials: "include",
});
if (!res.ok) throw new Error("Failed to fetch");
const journeys = await res.json();
availableJourneys = journeys;
populateJourneySelect(journeys);
} catch (err) {
showToast("Error loading journeys", "error");
}
}
function populateJourneySelect(journeys) {
const select = document.getElementById("journey-select");
select.innerHTML =
'<option value="">-- Choose a journey --</option><option value="all">Show All Journeys</option>';
journeys.forEach((j) => {
const opt = document.createElement("option");
opt.value = j.id;
opt.textContent = j.title;
select.appendChild(opt);
});
}
async function loadJourney(journeyId) {
try {
const res = await fetch(
`${API_BASE}/journeys/${journeyId}`,
{ credentials: "include" },
);
if (!res.ok) throw new Error("Failed to load");
const journey = await res.json();
displayJourneyInfo(journey);
} catch (err) {
showToast("Error loading journey", "error");
}
}
function displayJourneyInfo(journey) {
document.getElementById("info-title").textContent =
journey.title;
document.getElementById("info-description").textContent =
journey.description || "-";
document.getElementById("info-marker-count").textContent =
journey.markers ? journey.markers.length : 0;
document.getElementById("info-date").textContent =
new Date(journey.created_at).toLocaleDateString() ||
"-";
document.getElementById(
"load-journey-btn",
).dataset.journey = JSON.stringify(journey);
document.getElementById("edit-journey-btn").disabled = false;
document.getElementById("delete-journey-btn").disabled = false;
}
function displayAllJourneysInfo() {
const markerCount = availableJourneys.reduce(
(total, journey) => total + (journey.markers ? journey.markers.length : 0),
0,
);
document.getElementById("info-title").textContent = "All Journeys";
document.getElementById("info-description").textContent =
`${availableJourneys.length} journeys selected`;
document.getElementById("info-marker-count").textContent = markerCount;
document.getElementById("info-date").textContent = "-";
document.getElementById("load-journey-btn").dataset.journey = JSON.stringify({
type: "all",
});
document.getElementById("edit-journey-btn").disabled = true;
document.getElementById("delete-journey-btn").disabled = true;
}
async function saveJourneyToAPI() {
const title = document
.getElementById("journey-title")
.value.trim();
if (!title) {
showToast("Please enter a title", "error");
return;
}
const description = document.getElementById(
"journey-description",
).value;
const markers = currentJourney.markers.map((m) => {
const latlng = normalizeLatLng(m.getLatLng());
m.setLatLng(latlng);
const content = m._content || {};
return {
lat: latlng.lat,
lng: latlng.lng,
title: content.title || "",
date: content.date || "",
description: content.description || "",
videoUrl: content.videoUrl || "",
images: content.images || [],
};
});
const visibility = document.getElementById('journey-visibility').value;
const payload = { title, description, markers, visibility };
try {
let res;
if (currentJourney.id) {
res = await fetch(
`${API_BASE}/journeys/${currentJourney.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
credentials: "include",
},
);
} else {
res = await fetch(`${API_BASE}/journeys`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "include",
});
}
if (!res.ok) throw new Error("Save failed");
const data = await res.json();
currentJourney.id = data.id;
showToast("Journey saved!");
fetchJourneys();
} catch (err) {
showToast("Error saving journey", "error");
}
}
async function deleteJourneyFromAPI(journeyId) {
if (!confirm("Delete this journey?")) return;
try {
const res = await fetch(
`${API_BASE}/journeys/${journeyId}`,
{
method: "DELETE",
credentials: "include",
},
);
if (!res.ok) throw new Error("Delete failed");
showToast("Journey deleted");
fetchJourneys();
document.getElementById("info-title").textContent = "-";
document.getElementById(
"info-description",
).textContent = "-";
document.getElementById(
"info-marker-count",
).textContent = "0";
document.getElementById("info-date").textContent = "-";
} catch (err) {
showToast("Error deleting journey", "error");
}
}
function loadJourneyMarkers(journey) {
currentJourney.markers.forEach((m) => map.removeLayer(m));
currentJourney.markers = [];
document.getElementById('journey-visibility').value = journey.visibility || 'private';
document.getElementById(
"current-markers-container",
).innerHTML =
'<p class="empty-message">No markers yet. Click on the map to add markers.</p>';
if (journey.markers) {
journey.markers.forEach((m) => {
const latlng = L.latLng(m.lat, m.lng);
const marker = createMarker(latlng, {
title: m.title,
date: m.date,
description: m.description,
videoUrl: m.videoUrl,
images: m.images || [],
});
marker._content = { ...m };
});
}
currentJourney.id = journey.id;
document.getElementById("journey-title").value =
journey.title;
document.getElementById("journey-description").value =
journey.description || "";
setMode("create");
showToast("Journey loaded on map");
}
function loadAllJourneyMarkers() {
currentJourney.markers.forEach((m) => map.removeLayer(m));
currentJourney.markers = [];
currentJourney.id = null;
currentJourney.title = "All Journeys";
currentJourney.description = "";
document.getElementById("current-markers-container").innerHTML =
'<p class="empty-message">No markers found in the selected journeys.</p>';
availableJourneys.forEach((journey) => {
(journey.markers || []).forEach((m) => {
const latlng = L.latLng(m.lat, m.lng);
const markerTitle = m.title || "Untitled";
const displayTitle = `${journey.title}: ${markerTitle}`;
const marker = createMarker(latlng, {
title: displayTitle,
date: m.date,
description: m.description,
videoUrl: m.videoUrl,
images: m.images || [],
});
marker._content = {
...m,
title: displayTitle,
journeyId: journey.id,
journeyTitle: journey.title,
};
});
});
document.getElementById("journey-title").value = "All Journeys";
document.getElementById("journey-description").value = "";
setMode("view");
if (currentJourney.markers.length > 0) {
journeyPath.setLatLngs([]);
map.fitBounds(L.featureGroup(currentJourney.markers).getBounds());
showToast("All journey markers loaded");
} else {
updateJourneyPath();
showToast("No markers found", "warning");
}
}
// ==================== UI HELPERS ================
function showToast(msg, type = "success") {
const toast = document.getElementById("toast");
document.getElementById("toast-message").textContent = msg;
toast.style.display = "block";
setTimeout(() => {
toast.style.display = "none";
}, 3000);
}
function setMode(mode) {
const createBtn = document.getElementById("mode-create");
const viewBtn = document.getElementById("mode-view");
const createPanel = document.getElementById("create-panel");
const viewPanel = document.getElementById("view-panel");
const indicator = document.getElementById("mode-indicator");
if (mode === "create") {
createBtn.classList.add("active");
viewBtn.classList.remove("active");
createPanel.classList.add("active-panel");
viewPanel.classList.remove("active-panel");
indicator.querySelector(".indicator-text").textContent =
"Creating: New Journey";
indicator.querySelector(".indicator-dot").className =
"indicator-dot creating";
} else {
viewBtn.classList.add("active");
createBtn.classList.remove("active");
viewPanel.classList.add("active-panel");
createPanel.classList.remove("active-panel");
indicator.querySelector(".indicator-text").textContent =
"Viewing Journeys";
indicator.querySelector(".indicator-dot").className =
"indicator-dot viewing";
}
}
function clearAllMarkers() {
currentJourney.markers.forEach((m) => map.removeLayer(m));
currentJourney.markers = [];
document.getElementById(
"current-markers-container",
).innerHTML =
'<p class="empty-message">No markers yet. Click on the map to add markers.</p>';
updateJourneyPath();
showToast("All markers cleared");
}
// ==================== EVENT LISTENERS (run after auth) ====================
function bindEventListeners() {
document
.getElementById("mode-create")
.addEventListener("click", () => setMode("create"));
document
.getElementById("mode-view")
.addEventListener("click", () => {
setMode("view");
fetchJourneys();
});
document
.getElementById("clear-markers-btn")
.addEventListener("click", clearAllMarkers);
document
.getElementById("save-journey-btn")
.addEventListener("click", saveJourneyToAPI);
document
.getElementById("close-modal")
.addEventListener("click", closeModal);
document
.getElementById("close-modal-footer")
.addEventListener("click", closeModal);
document
.getElementById("save-marker")
.addEventListener("click", saveMarker);
document
.getElementById("delete-marker")
.addEventListener("click", deleteMarkerFromMap);
document
.getElementById("toggle-sidebar")
.addEventListener("click", () => {
document
.querySelector(".sidebar")
.classList.toggle("collapsed");
});
document
.getElementById("zoom-in")
.addEventListener("click", () => map.zoomIn());
document
.getElementById("zoom-out")
.addEventListener("click", () => map.zoomOut());
document
.getElementById("fit-all")
.addEventListener("click", () => {
if (currentJourney.markers.length > 0) {
const group = L.featureGroup(
currentJourney.markers,
);
map.fitBounds(group.getBounds());
} else {
showToast("No markers to fit", "warning");
}
});
document
.getElementById("locate-me")
.addEventListener("click", () => {
map.locate({ setView: true, maxZoom: 16 });
});
document
.getElementById("journey-select")
.addEventListener("change", (e) => {
const val = e.target.value;
if (val === "all") {
displayAllJourneysInfo();
} else if (val) {
loadJourney(val);
}
});
document
.getElementById("load-journey-btn")
.addEventListener("click", () => {
const journeyData =
document.getElementById("load-journey-btn")
.dataset.journey;
if (!journeyData) return;
const journey = JSON.parse(journeyData);
if (journey.type === "all") {
loadAllJourneyMarkers();
} else {
loadJourneyMarkers(journey);
}
});
document
.getElementById("edit-journey-btn")
.addEventListener("click", () => {
const journeyData =
document.getElementById("load-journey-btn")
.dataset.journey;
if (journeyData)
loadJourneyMarkers(JSON.parse(journeyData));
});
document
.getElementById("delete-journey-btn")
.addEventListener("click", () => {
const journeyData =
document.getElementById("load-journey-btn")
.dataset.journey;
if (journeyData)
deleteJourneyFromAPI(
JSON.parse(journeyData).id,
);
});
document
.getElementById("add-marker-btn")
.addEventListener("click", () => {
setMode("create");
showToast("Click on the map to add a marker");
});
}
// Expose functions globally (optional)
window.createMarker = createMarker;
window.updateJourneyPath = updateJourneyPath;
window.clearAllMarkers = clearAllMarkers;
window.setMode = setMode;
// ==================== START ====================
function startMap() {
initMap();
// Check for journey parameter
const urlParams = new URLSearchParams(window.location.search);
const journeyToLoad = urlParams.get('journey');
if (journeyToLoad) {
fetch(`${API_BASE}/journeys/${journeyToLoad}`, { credentials: 'include' })
.then(res => res.json())
.then(journey => {
if (journey.can_edit) {
loadJourneyMarkers(journey);
} else {
showToast('You cannot edit this journey', true);
}
})
.catch(err => console.error(err));
}
bindEventListeners();
}
window.startMap = startMap;
})(); // end of IIFE
// ==================== SIDEBAR HELPER (outside IIFE) ====================
function initSidebar() {
const sidebar = document.querySelector('.sidebar');
if (!sidebar) return;
const isMobile = window.innerWidth <= 768;
if (isMobile) {
sidebar.classList.add('collapsed');
} else {
sidebar.classList.remove('collapsed');
}
}
// ==================== AUTHENTICATION CHECK ====================
document.addEventListener("DOMContentLoaded", async () => {
const authenticated = await checkAuthAndRedirect();
if (authenticated) {
updateUserMenu();
window.addEventListener('resize', initSidebar);
initSidebar();
window.startMap();
}
});

View File

@ -158,69 +158,6 @@
<div id="toast" class="toast"></div>
<script src="js/auth.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Tab switching
document.querySelectorAll('.auth-tab').forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.tab;
document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('.auth-form').forEach(f => f.classList.remove('active'));
document.getElementById(`${target}-form`).classList.add('active');
});
});
// Login button
document.getElementById('login-submit').addEventListener('click', async () => {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
if (!username || !password) {
showToast('Please enter username and password', true);
return;
}
try {
const res = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
showToast(`Welcome, ${data.username}`);
window.location.href = 'map-page.html';
} catch (err) {
showToast(err.message, true);
}
});
// Register button
document.getElementById('register-submit').addEventListener('click', async () => {
const username = document.getElementById('register-username').value.trim();
const password = document.getElementById('register-password').value;
if (!username || !password) {
showToast('Please enter username and password', true);
return;
}
if (password.length < 4) {
showToast('Password must be at least 4 characters', true);
return;
}
try {
const res = await fetch(`${API_BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Registration failed');
showToast(`Registered as ${data.username}. Logging in...`);
window.location.href = 'map-page.html';
} catch (err) {
showToast(err.message, true);
}
});
});
</script>
<script src="js/login.js"></script>
</body>
</html>

View File

@ -913,641 +913,6 @@
</div>
<div id="toast" class="toast"><p id="toast-message"></p></div>
<script src="js/auth.js"></script>
<script>
// ==================== MAP CODE ====================
(function () {
// ==================== STATE =====================
let map;
let currentJourney = {
id: null,
title: "",
description: "",
markers: [],
};
let journeyPath;
let activeMarker = null;
// ==================== MAP INIT ==================
function initMap() {
map = L.map("map", {
worldCopyJump: true,
}).setView([46.8182, 8.2275], 8);
L.tileLayer(
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
).addTo(map);
journeyPath = L.polyline([], {
color: "#4263eb",
weight: 4,
}).addTo(map);
map.on("click", function (e) {
if (
document
.getElementById("mode-create")
.classList.contains("active")
) {
createMarker(normalizeLatLng(e.latlng), { title: "New Marker" });
}
});
setTimeout(() => map.invalidateSize(), 300);
}
// ==================== MARKER FUNCTIONS ==========
function normalizeLatLng(latlng) {
return L.latLng(
latlng.lat,
((((latlng.lng + 180) % 360) + 360) % 360) - 180,
);
}
function createMarker(latlng, content = {}) {
latlng = normalizeLatLng(latlng);
const marker = L.marker(latlng, {
draggable: true,
title: content.title || "Untitled",
}).addTo(map);
marker.bindPopup(
`<strong>${content.title || "Untitled"}</strong>`,
);
marker.on("click", () => openMarkerEditor(marker));
marker.on("dragend", () => {
marker.setLatLng(normalizeLatLng(marker.getLatLng()));
updateJourneyPath();
});
marker._content = {
...content,
lat: latlng.lat,
lng: latlng.lng,
};
currentJourney.markers.push(marker);
updateJourneyPath();
addMarkerToList(marker, latlng, content);
return marker;
}
function addMarkerToList(marker, latlng, content) {
const container = document.getElementById(
"current-markers-container",
);
const empty = container.querySelector(".empty-message");
if (empty) empty.remove();
const div = document.createElement("div");
div.className = "marker-item";
div.dataset.lat = latlng.lat;
div.dataset.lng = latlng.lng;
div.innerHTML = `
<div class="marker-title">${content.title || "Untitled"}</div>
<div class="marker-coords">${latlng.lat.toFixed(4)}, ${latlng.lng.toFixed(4)}</div>
`;
div.addEventListener("click", () => {
map.flyTo(latlng, 12);
openMarkerEditor(marker);
});
container.appendChild(div);
}
function updateJourneyPath() {
const coords = currentJourney.markers.map((m) => [
m.getLatLng().lat,
m.getLatLng().lng,
]);
journeyPath.setLatLngs(coords);
}
function getUploadUrl(path) {
if (!path) return "";
if (path.startsWith("http")) return path;
return API_BASE.replace("/api", path);
}
function renderMarkerImages(images = []) {
const preview = document.getElementById("marker-image-preview");
preview.innerHTML = "";
images.forEach((image, index) => {
const item = document.createElement("div");
item.className = "image-preview";
item.innerHTML = `
<img src="${getUploadUrl(image.url)}" alt="${image.originalName || "Marker image"}">
<button type="button" class="remove-image-btn" data-index="${index}" aria-label="Remove image">
<i class="fas fa-times"></i>
</button>
`;
item.querySelector(".remove-image-btn").addEventListener("click", () => {
if (!activeMarker) return;
const content = activeMarker._content || {};
content.images = (content.images || []).filter((_, i) => i !== index);
activeMarker._content = content;
renderMarkerImages(content.images);
});
preview.appendChild(item);
});
}
async function uploadMarkerImages(files) {
if (!files.length) return [];
const formData = new FormData();
files.forEach((file) => formData.append("images", file));
const res = await fetch(`${API_BASE}/uploads/images`, {
method: "POST",
body: formData,
credentials: "include",
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Image upload failed");
return data.images || [];
}
// ==================== MODAL =====================
function openMarkerEditor(marker) {
activeMarker = marker;
const latlng = marker.getLatLng();
const content = marker._content || {};
document.getElementById("marker-title").value =
content.title || "";
document.getElementById("marker-date").value =
content.date || "";
document.getElementById("marker-text").value =
content.description || "";
document.getElementById("video-url").value =
content.videoUrl || "";
document.getElementById("marker-images").value = "";
renderMarkerImages(content.images || []);
document.getElementById("marker-coords").value =
`${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`;
document
.getElementById("marker-modal")
.classList.add("active");
}
function closeModal() {
document
.getElementById("marker-modal")
.classList.remove("active");
activeMarker = null;
}
async function saveMarker() {
if (!activeMarker) return;
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;
try {
const uploadedImages = await uploadMarkerImages(Array.from(imageInput.files));
images = [...existingImages, ...uploadedImages];
} catch (err) {
showToast(err.message, "error");
return;
}
// Update marker's tooltip title and popup
activeMarker.options.title = title;
activeMarker.setPopupContent(`<strong>${title}</strong>`);
// Store content for saving
activeMarker._content = { title, date, description, videoUrl, images };
// Update the list item in the sidebar
const latlng = activeMarker.getLatLng();
const items = document.querySelectorAll("#current-markers-container .marker-item");
for (let item of items) {
if (item.dataset.lat == latlng.lat && item.dataset.lng == latlng.lng) {
item.querySelector(".marker-title").textContent = title;
break;
}
}
closeModal();
showToast("Saving journey...");
await saveJourneyToAPI();
}
function deleteMarkerFromMap() {
if (!activeMarker) return;
const latlng = activeMarker.getLatLng();
map.removeLayer(activeMarker);
const idx = currentJourney.markers.indexOf(activeMarker);
if (idx > -1) currentJourney.markers.splice(idx, 1);
const items = document.querySelectorAll(
"#current-markers-container .marker-item",
);
for (let item of items) {
if (
item.dataset.lat == latlng.lat &&
item.dataset.lng == latlng.lng
) {
item.remove();
break;
}
}
if (currentJourney.markers.length === 0) {
document.getElementById(
"current-markers-container",
).innerHTML =
'<p class="empty-message">No markers yet. Click on the map to add markers.</p>';
}
updateJourneyPath();
closeModal();
showToast("Marker deleted");
}
// ==================== API INTERACTION (with credentials) ====================
async function fetchJourneys() {
try {
const res = await fetch(`${API_BASE}/journeys`, {
credentials: "include",
});
if (!res.ok) throw new Error("Failed to fetch");
const journeys = await res.json();
populateJourneySelect(journeys);
} catch (err) {
showToast("Error loading journeys", "error");
}
}
function populateJourneySelect(journeys) {
const select = document.getElementById("journey-select");
select.innerHTML =
'<option value="">-- Choose a journey --</option><option value="all">Show All Journeys</option>';
journeys.forEach((j) => {
const opt = document.createElement("option");
opt.value = j.id;
opt.textContent = j.title;
select.appendChild(opt);
});
}
async function loadJourney(journeyId) {
try {
const res = await fetch(
`${API_BASE}/journeys/${journeyId}`,
{ credentials: "include" },
);
if (!res.ok) throw new Error("Failed to load");
const journey = await res.json();
displayJourneyInfo(journey);
} catch (err) {
showToast("Error loading journey", "error");
}
}
function displayJourneyInfo(journey) {
document.getElementById("info-title").textContent =
journey.title;
document.getElementById("info-description").textContent =
journey.description || "-";
document.getElementById("info-marker-count").textContent =
journey.markers ? journey.markers.length : 0;
document.getElementById("info-date").textContent =
new Date(journey.created_at).toLocaleDateString() ||
"-";
document.getElementById(
"load-journey-btn",
).dataset.journey = JSON.stringify(journey);
}
async function saveJourneyToAPI() {
const title = document
.getElementById("journey-title")
.value.trim();
if (!title) {
showToast("Please enter a title", "error");
return;
}
const description = document.getElementById(
"journey-description",
).value;
const markers = currentJourney.markers.map((m) => {
const latlng = normalizeLatLng(m.getLatLng());
m.setLatLng(latlng);
const content = m._content || {};
return {
lat: latlng.lat,
lng: latlng.lng,
title: content.title || "",
date: content.date || "",
description: content.description || "",
videoUrl: content.videoUrl || "",
images: content.images || [],
};
});
const visibility = document.getElementById('journey-visibility').value;
const payload = { title, description, markers, visibility };
try {
let res;
if (currentJourney.id) {
res = await fetch(
`${API_BASE}/journeys/${currentJourney.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
credentials: "include",
},
);
} else {
res = await fetch(`${API_BASE}/journeys`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "include",
});
}
if (!res.ok) throw new Error("Save failed");
const data = await res.json();
currentJourney.id = data.id;
showToast("Journey saved!");
fetchJourneys();
} catch (err) {
showToast("Error saving journey", "error");
}
}
async function deleteJourneyFromAPI(journeyId) {
if (!confirm("Delete this journey?")) return;
try {
const res = await fetch(
`${API_BASE}/journeys/${journeyId}`,
{
method: "DELETE",
credentials: "include",
},
);
if (!res.ok) throw new Error("Delete failed");
showToast("Journey deleted");
fetchJourneys();
document.getElementById("info-title").textContent = "-";
document.getElementById(
"info-description",
).textContent = "-";
document.getElementById(
"info-marker-count",
).textContent = "0";
document.getElementById("info-date").textContent = "-";
} catch (err) {
showToast("Error deleting journey", "error");
}
}
function loadJourneyMarkers(journey) {
currentJourney.markers.forEach((m) => map.removeLayer(m));
currentJourney.markers = [];
document.getElementById('journey-visibility').value = journey.visibility || 'private';
document.getElementById(
"current-markers-container",
).innerHTML =
'<p class="empty-message">No markers yet. Click on the map to add markers.</p>';
if (journey.markers) {
journey.markers.forEach((m) => {
const latlng = L.latLng(m.lat, m.lng);
const marker = createMarker(latlng, {
title: m.title,
date: m.date,
description: m.description,
videoUrl: m.videoUrl,
images: m.images || [],
});
marker._content = { ...m };
});
}
currentJourney.id = journey.id;
document.getElementById("journey-title").value =
journey.title;
document.getElementById("journey-description").value =
journey.description || "";
setMode("create");
showToast("Journey loaded on map");
}
// ==================== UI HELPERS ================
function showToast(msg, type = "success") {
const toast = document.getElementById("toast");
document.getElementById("toast-message").textContent = msg;
toast.style.display = "block";
setTimeout(() => {
toast.style.display = "none";
}, 3000);
}
function setMode(mode) {
const createBtn = document.getElementById("mode-create");
const viewBtn = document.getElementById("mode-view");
const createPanel = document.getElementById("create-panel");
const viewPanel = document.getElementById("view-panel");
const indicator = document.getElementById("mode-indicator");
if (mode === "create") {
createBtn.classList.add("active");
viewBtn.classList.remove("active");
createPanel.classList.add("active-panel");
viewPanel.classList.remove("active-panel");
indicator.querySelector(".indicator-text").textContent =
"Creating: New Journey";
indicator.querySelector(".indicator-dot").className =
"indicator-dot creating";
} else {
viewBtn.classList.add("active");
createBtn.classList.remove("active");
viewPanel.classList.add("active-panel");
createPanel.classList.remove("active-panel");
indicator.querySelector(".indicator-text").textContent =
"Viewing Journeys";
indicator.querySelector(".indicator-dot").className =
"indicator-dot viewing";
}
}
function clearAllMarkers() {
currentJourney.markers.forEach((m) => map.removeLayer(m));
currentJourney.markers = [];
document.getElementById(
"current-markers-container",
).innerHTML =
'<p class="empty-message">No markers yet. Click on the map to add markers.</p>';
updateJourneyPath();
showToast("All markers cleared");
}
// ==================== EVENT LISTENERS (run after auth) ====================
function bindEventListeners() {
document
.getElementById("mode-create")
.addEventListener("click", () => setMode("create"));
document
.getElementById("mode-view")
.addEventListener("click", () => {
setMode("view");
fetchJourneys();
});
document
.getElementById("clear-markers-btn")
.addEventListener("click", clearAllMarkers);
document
.getElementById("save-journey-btn")
.addEventListener("click", saveJourneyToAPI);
document
.getElementById("close-modal")
.addEventListener("click", closeModal);
document
.getElementById("close-modal-footer")
.addEventListener("click", closeModal);
document
.getElementById("save-marker")
.addEventListener("click", saveMarker);
document
.getElementById("delete-marker")
.addEventListener("click", deleteMarkerFromMap);
document
.getElementById("toggle-sidebar")
.addEventListener("click", () => {
document
.querySelector(".sidebar")
.classList.toggle("collapsed");
});
document
.getElementById("zoom-in")
.addEventListener("click", () => map.zoomIn());
document
.getElementById("zoom-out")
.addEventListener("click", () => map.zoomOut());
document
.getElementById("fit-all")
.addEventListener("click", () => {
if (currentJourney.markers.length > 0) {
const group = L.featureGroup(
currentJourney.markers,
);
map.fitBounds(group.getBounds());
} else {
showToast("No markers to fit", "warning");
}
});
document
.getElementById("locate-me")
.addEventListener("click", () => {
map.locate({ setView: true, maxZoom: 16 });
});
document
.getElementById("journey-select")
.addEventListener("change", (e) => {
const val = e.target.value;
if (val && val !== "all") loadJourney(val);
});
document
.getElementById("load-journey-btn")
.addEventListener("click", () => {
const journeyData =
document.getElementById("load-journey-btn")
.dataset.journey;
if (journeyData)
loadJourneyMarkers(JSON.parse(journeyData));
});
document
.getElementById("edit-journey-btn")
.addEventListener("click", () => {
const journeyData =
document.getElementById("load-journey-btn")
.dataset.journey;
if (journeyData)
loadJourneyMarkers(JSON.parse(journeyData));
});
document
.getElementById("delete-journey-btn")
.addEventListener("click", () => {
const journeyData =
document.getElementById("load-journey-btn")
.dataset.journey;
if (journeyData)
deleteJourneyFromAPI(
JSON.parse(journeyData).id,
);
});
document
.getElementById("add-marker-btn")
.addEventListener("click", () => {
setMode("create");
showToast("Click on the map to add a marker");
});
}
// Expose functions globally (optional)
window.createMarker = createMarker;
window.updateJourneyPath = updateJourneyPath;
window.clearAllMarkers = clearAllMarkers;
window.setMode = setMode;
// ==================== START ====================
function startMap() {
initMap();
// Check for journey parameter
const urlParams = new URLSearchParams(window.location.search);
const journeyToLoad = urlParams.get('journey');
if (journeyToLoad) {
fetch(`${API_BASE}/journeys/${journeyToLoad}`, { credentials: 'include' })
.then(res => res.json())
.then(journey => {
if (journey.can_edit) {
loadJourneyMarkers(journey);
} else {
showToast('You cannot edit this journey', true);
}
})
.catch(err => console.error(err));
}
bindEventListeners();
}
window.startMap = startMap;
})(); // end of IIFE
// ==================== SIDEBAR HELPER (outside IIFE) ====================
function initSidebar() {
const sidebar = document.querySelector('.sidebar');
if (!sidebar) return;
const isMobile = window.innerWidth <= 768;
if (isMobile) {
sidebar.classList.add('collapsed');
} else {
sidebar.classList.remove('collapsed');
}
}
// ==================== AUTHENTICATION CHECK ====================
document.addEventListener("DOMContentLoaded", async () => {
const authenticated = await checkAuthAndRedirect();
if (authenticated) {
updateUserMenu();
window.addEventListener('resize', initSidebar);
initSidebar();
window.startMap();
}
});
</script>
<script src="js/map-page.js"></script>
</body>
</html>