712 lines
28 KiB
JavaScript

// ==================== MAP CODE ====================
(function () {
// ==================== STATE =====================
let map;
let currentJourney = {
id: null,
title: "",
description: "",
markers: [],
};
let availableJourneys = [];
let journeyPath;
let activeMarker = null;
// ==================== MAP INIT ==================
function initMap() {
map = L.map("map", {
worldCopyJump: true,
}).setView([46.8182, 8.2275], 8);
L.tileLayer(
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
).addTo(map);
journeyPath = L.polyline([], {
color: "#4263eb",
weight: 4,
}).addTo(map);
map.on("click", function (e) {
if (
document
.getElementById("mode-create")
.classList.contains("active")
) {
createMarker(normalizeLatLng(e.latlng), { title: "New Marker" });
}
});
setTimeout(() => map.invalidateSize(), 300);
}
// ==================== MARKER FUNCTIONS ==========
function normalizeLatLng(latlng) {
return L.latLng(
latlng.lat,
((((latlng.lng + 180) % 360) + 360) % 360) - 180,
);
}
function createMarker(latlng, content = {}) {
latlng = normalizeLatLng(latlng);
const marker = L.marker(latlng, {
draggable: true,
title: content.title || "Untitled",
}).addTo(map);
marker.bindPopup(
`<strong>${content.title || "Untitled"}</strong>`,
);
marker.on("click", () => openMarkerEditor(marker));
marker.on("dragend", () => {
marker.setLatLng(normalizeLatLng(marker.getLatLng()));
updateJourneyPath();
});
marker._content = {
...content,
lat: latlng.lat,
lng: latlng.lng,
};
currentJourney.markers.push(marker);
updateJourneyPath();
addMarkerToList(marker, latlng, content);
return marker;
}
function addMarkerToList(marker, latlng, content) {
const container = document.getElementById(
"current-markers-container",
);
const empty = container.querySelector(".empty-message");
if (empty) empty.remove();
const div = document.createElement("div");
div.className = "marker-item";
div.dataset.lat = latlng.lat;
div.dataset.lng = latlng.lng;
div.innerHTML = `
<div class="marker-title">${content.title || "Untitled"}</div>
<div class="marker-coords">${latlng.lat.toFixed(4)}, ${latlng.lng.toFixed(4)}</div>
`;
div.addEventListener("click", () => {
map.flyTo(latlng, 12);
openMarkerEditor(marker);
});
container.appendChild(div);
}
function updateJourneyPath() {
const coords = currentJourney.markers.map((m) => [
m.getLatLng().lat,
m.getLatLng().lng,
]);
journeyPath.setLatLngs(coords);
}
function getUploadUrl(path) {
if (!path) return "";
if (path.startsWith("http")) return path;
return API_BASE.replace("/api", path);
}
function renderMarkerImages(images = []) {
const preview = document.getElementById("marker-image-preview");
preview.innerHTML = "";
images.forEach((image, index) => {
const item = document.createElement("div");
item.className = "image-preview";
item.innerHTML = `
<img src="${getUploadUrl(image.url)}" alt="${image.originalName || "Marker image"}">
<button type="button" class="remove-image-btn" data-index="${index}" aria-label="Remove image">
<i class="fas fa-times"></i>
</button>
`;
item.querySelector(".remove-image-btn").addEventListener("click", () => {
if (!activeMarker) return;
const content = activeMarker._content || {};
content.images = (content.images || []).filter((_, i) => i !== index);
activeMarker._content = content;
renderMarkerImages(content.images);
});
preview.appendChild(item);
});
}
function updateMarkerMarkdownPreview(markdown) {
const preview = document.getElementById("marker-markdown-preview");
if (!preview) return;
preview.innerHTML = renderMarkdown(markdown || "") || '<p class="markdown-empty">Nothing to preview yet.</p>';
}
async function uploadMarkerImages(files) {
if (!files.length) return [];
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 || "";
updateMarkerMarkdownPreview(content.description || "");
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 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, 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 || "",
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,
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,
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");
});
document
.getElementById("marker-text")
.addEventListener("input", (e) => updateMarkerMarkdownPreview(e.target.value));
}
// 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();
}
});