diff --git a/blog-list.html b/blog-list.html index 030c223..33f72a6 100644 --- a/blog-list.html +++ b/blog-list.html @@ -264,88 +264,6 @@
- + diff --git a/blog-post.html b/blog-post.html index 74403fb..e30835c 100644 --- a/blog-post.html +++ b/blog-post.html @@ -311,358 +311,6 @@
- + diff --git a/journey-edit.html b/journey-edit.html index 0291a60..f0bb65d 100644 --- a/journey-edit.html +++ b/journey-edit.html @@ -381,398 +381,6 @@
- + diff --git a/js/blog-list.js b/js/blog-list.js new file mode 100644 index 0000000..1e1d6de --- /dev/null +++ b/js/blog-list.js @@ -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 = '

Failed to load journeys. Make sure the backend is running.

'; + } +} + +function renderJourneys(journeys) { + const container = document.getElementById('posts-grid'); + if (!journeys.length) { + container.innerHTML = ` +
+

No journeys yet. Create one on the map!

+
+ `; + return; + } + + container.innerHTML = journeys.map(journey => ` +
+ ${journey.image ? `${journey.title}` : '
'} +
+

${escapeHtml(journey.title)}

+
+ ${new Date(journey.created_at).toLocaleDateString()} + ${journey.markers ? ` ${journey.markers.length} chapters` : ''} + ${getJourneyBadges(journey)} +
+
${escapeHtml(journey.description || journey.markers?.[0]?.text?.substring(0, 150) + '…')}
+
+
+ `).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) => `${badge}`).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); + }); + }); +}); diff --git a/js/blog-post.js b/js/blog-post.js new file mode 100644 index 0000000..0ab2692 --- /dev/null +++ b/js/blog-post.js @@ -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 = '
'; + currentJourney.markers.forEach((marker, idx) => { + const title = marker.title || `Chapter ${idx + 1}`; + const date = marker.date ? `${new Date(marker.date).toLocaleDateString()}` : ''; + const content = marker.description ? escapeHtml(marker.description).replace(/\n/g, '
') : ''; + marker.images = getMarkerImages(marker); + const images = getMarkerImagesHtml(marker.images, idx, canEdit); + const video = marker.videoUrl ? `` : ''; + chaptersHtml += ` +
+
+

${escapeHtml(title)}

+ ${date} +
+ ${images} + ${canEdit ? getImageToolsHtml(idx) : ''} + ${video} +
${content}
+
+ `; + }); + chaptersHtml += '
'; + } else { + chaptersHtml = '

No chapters yet.

'; + } + + container.innerHTML = ` +
+

${escapeHtml(currentJourney.title)}

+
+ ${new Date(currentJourney.created_at).toLocaleDateString()} + ${currentJourney.visibility === 'public' ? 'Public' : ''} + ${currentJourney.visibility === 'shared' && !isOwner ? `${canEdit ? 'Shared edit' : 'Shared'}` : ''} +
+ ${currentJourney.image ? `${currentJourney.title}` : ''} +
${escapeHtml(currentJourney.description).replace(/\n/g, '
')}
+ ${chaptersHtml} + ${canEdit || isOwner ? ` +
+ ${canEdit ? ` Edit` : ''} + ${isOwner ? `` : ''} +
+ ` : ''} +
+ `; + + 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 ` +
+ ${escapeAttribute(alt)} + ${canEdit ? ` + + ` : ''} +
+ `; + }).join(''); + + if (!imageHtml) return ''; + return `
${imageHtml}
`; +} + +function getImageToolsHtml(markerIndex) { + return ` +
+ +
+ `; +} + +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 = ` +

Comments

+

No comments yet. Be the first to comment!

+ ${canComment && currentUser ? getCommentFormHtml() : (!currentUser ? '

Login to comment.

' : '')} + `; + if (canComment && currentUser) attachCommentForm(); + return; + } + + let commentsHtml = '

Comments

'; + comments.forEach(comment => { + const isOwnerOrAuthor = currentUser && (currentUser.id === comment.author_id || currentUser.id === currentJourney.owner_id); + commentsHtml += ` +
+
+ ${escapeHtml(comment.author_name)} – ${new Date(comment.created_at).toLocaleString()} + ${isOwnerOrAuthor ? `` : ''} +
+

${escapeHtml(comment.text)}

+
+ `; + }); + container.innerHTML = commentsHtml + (canComment && currentUser ? getCommentFormHtml() : (canComment ? '

Login to comment.

' : '')); + + document.querySelectorAll('.delete-comment').forEach(btn => { + btn.addEventListener('click', () => deleteComment(parseInt(btn.dataset.id))); + }); + if (canComment && currentUser) attachCommentForm(); +} + +function getCommentFormHtml() { + return ` +
+ + +
+ `; +} + +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(); +}); diff --git a/js/journey-edit.js b/js/journey-edit.js new file mode 100644 index 0000000..aea6971 --- /dev/null +++ b/js/journey-edit.js @@ -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 ` + + `; +} + +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 = '

No other users found.

'; + editContainer.innerHTML = '

No other users found.

'; + 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 ` +
+ ${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 ` +
+ ${escapeAttribute(alt)} + +
+ `; + }).join('')} +
+ `; +} + +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 = '

No chapters yet. Click "Add Chapter" to create one.

'; + return; + } + container.innerHTML = markersData.map((marker, idx) => ` +
+
+

Chapter ${idx+1}

+ +
+
+ ${marker.lat.toFixed(6)}, ${marker.lng.toFixed(6)} +
+
+ + +
+
+ + +
+
+ + +
+
+ + + ${renderMarkerImages(marker, idx)} +
+
+ + +
+
+ `).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); +}); diff --git a/js/login.js b/js/login.js new file mode 100644 index 0000000..043beb9 --- /dev/null +++ b/js/login.js @@ -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); + } + }); + }); diff --git a/js/map-page.js b/js/map-page.js new file mode 100644 index 0000000..2989fc1 --- /dev/null +++ b/js/map-page.js @@ -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: + '© OpenStreetMap', + }, + ).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( + `${content.title || "Untitled"}`, + ); + 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 = ` +
${content.title || "Untitled"}
+
${latlng.lat.toFixed(4)}, ${latlng.lng.toFixed(4)}
+ `; + 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 = ` + ${image.originalName || + + `; + 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(`${title}`); + + // 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 = + '

No markers yet. Click on the map to add markers.

'; + } + 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 = + ''; + 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 = + '

No markers yet. Click on the map to add markers.

'; + + 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 = + '

No markers found in the selected journeys.

'; + + 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 = + '

No markers yet. Click on the map to add markers.

'; + 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(); + } + }); diff --git a/login.html b/login.html index e2b67eb..d31cef5 100644 --- a/login.html +++ b/login.html @@ -158,69 +158,6 @@
- + diff --git a/map-page.html b/map-page.html index a47c5c4..d21c2e5 100644 --- a/map-page.html +++ b/map-page.html @@ -913,641 +913,6 @@

- +