Centralize page JavaScript and fix all-journeys map
This commit is contained in:
parent
4bf7dc2586
commit
7626417c46
@ -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>
|
||||
|
||||
354
blog-post.html
354
blog-post.html
@ -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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[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>
|
||||
|
||||
@ -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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[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
81
js/blog-list.js
Normal 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
351
js/blog-post.js
Normal 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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[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
391
js/journey-edit.js
Normal 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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[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
62
js/login.js
Normal 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
707
js/map-page.js
Normal 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:
|
||||
'© <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();
|
||||
}
|
||||
});
|
||||
65
login.html
65
login.html
@ -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>
|
||||
|
||||
637
map-page.html
637
map-page.html
@ -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:
|
||||
'© <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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user