const STORAGE_KEY = "routes.links.v1"; const AUTH_SESSION_KEY = "routes.authenticated.v1"; const AUTH_SECRET = window.ROUTES_SECRET; const authScreen = document.querySelector("#auth-screen"); const authForm = document.querySelector("#auth-form"); const authSecretInput = document.querySelector("#auth-secret"); const authMessage = document.querySelector("#auth-message"); const appShell = document.querySelector("#app-shell"); const logoutButton = document.querySelector("#logout-button"); const showFormButton = document.querySelector("#show-form-button"); const formPanel = document.querySelector("#route-editor"); const formTitle = document.querySelector("#form-title"); const form = document.querySelector("#route-form"); const idInput = document.querySelector("#route-id"); const nameInput = document.querySelector("#route-name"); const linkInput = document.querySelector("#route-link"); const dateInput = document.querySelector("#route-date"); const pictureInput = document.querySelector("#route-picture"); const descriptionInput = document.querySelector("#route-description"); const saveButton = document.querySelector("#save-button"); const cancelEditButton = document.querySelector("#cancel-edit"); const clearAllButton = document.querySelector("#clear-all"); const routeCount = document.querySelector("#route-count"); const emptyState = document.querySelector("#empty-state"); const routesList = document.querySelector("#routes-list"); const template = document.querySelector("#route-card-template"); let routes = sortRoutes(loadRoutes()); authForm.addEventListener("submit", authenticate); logoutButton.addEventListener("click", lockApp); showFormButton.addEventListener("click", startAddRoute); form.addEventListener("submit", saveRoute); cancelEditButton.addEventListener("click", resetForm); clearAllButton?.addEventListener("click", removeAllRoutes); routesList.addEventListener("click", handleRouteAction); document.addEventListener("click", closeMenusOnOutsideClick); document.addEventListener("keydown", closeMenusOnEscape); if (sessionStorage.getItem(AUTH_SESSION_KEY) === "yes") { unlockApp(); } else { lockApp(); } function authenticate(event) { event.preventDefault(); authMessage.textContent = ""; if (!AUTH_SECRET) { authMessage.textContent = "Missing config.js secret."; return; } if (authSecretInput.value.trim() !== AUTH_SECRET) { authMessage.textContent = "Wrong secret."; authSecretInput.select(); return; } sessionStorage.setItem(AUTH_SESSION_KEY, "yes"); unlockApp(); } function unlockApp() { authScreen.hidden = true; appShell.hidden = false; authSecretInput.value = ""; authMessage.textContent = ""; renderRoutes(); } function lockApp() { sessionStorage.removeItem(AUTH_SESSION_KEY); appShell.hidden = true; authScreen.hidden = false; resetForm(); authSecretInput.focus(); } function loadRoutes() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? []; } catch { return []; } } function storeRoutes() { localStorage.setItem(STORAGE_KEY, JSON.stringify(routes)); } function sortRoutes(routeList) { return [...routeList].sort((a, b) => { const dateA = a.date || "0000-00-00"; const dateB = b.date || "0000-00-00"; if (dateA !== dateB) { return dateA.localeCompare(dateB); } return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); }); } async function saveRoute(event) { event.preventDefault(); const id = idInput.value || createId(); const existingIndex = routes.findIndex((item) => item.id === id); const existingRoute = routes[existingIndex]; const route = { id, name: nameInput.value.trim(), link: linkInput.value.trim(), date: dateInput.value, picture: existingRoute?.picture || "", description: descriptionInput.value.trim(), updatedAt: new Date().toISOString(), }; if (!route.name || !route.link) { return; } if (!isValidUrl(route.link)) { alert("Please enter a valid Google Maps link."); return; } if (pictureInput.files[0]) { try { route.picture = await imageFileToDataUrl(pictureInput.files[0]); } catch { alert("Could not read that picture. Please choose another image."); return; } } if (existingIndex >= 0) { routes[existingIndex] = route; } else { routes.unshift(route); } routes = sortRoutes(routes); storeRoutes(); renderRoutes(); resetForm(); } function renderRoutes() { routesList.innerHTML = ""; routeCount.textContent = routes.length === 1 ? "1 route saved." : `${routes.length} routes saved.`; emptyState.classList.toggle("hidden", routes.length > 0); if (clearAllButton) { clearAllButton.hidden = routes.length === 0; } for (const route of routes) { const card = template.content.firstElementChild.cloneNode(true); const image = card.querySelector(".route-image"); const meta = card.querySelector(".route-meta"); const title = card.querySelector(".route-title"); const description = card.querySelector(".route-description"); card.dataset.id = route.id; meta.textContent = route.date ? formatDate(route.date) : ""; meta.hidden = !route.date; title.textContent = route.name; title.href = route.link; description.textContent = route.description; description.hidden = !route.description; if (route.picture) { image.hidden = false; image.style.backgroundImage = `url("${cssUrlEscape(route.picture)}")`; } routesList.append(card); } } function closeMenusOnOutsideClick(event) { if (event.target.closest(".route-menu")) { return; } closeRouteMenus(); } function closeMenusOnEscape(event) { if (event.key === "Escape") { closeRouteMenus(); } } function closeRouteMenus() { document.querySelectorAll(".route-menu[open]").forEach((menu) => menu.removeAttribute("open")); } function handleRouteAction(event) { const button = event.target.closest("button"); if (!button) { return; } const card = button.closest(".route-card"); const route = routes.find((item) => item.id === card?.dataset.id); if (!route) { return; } button.closest("details")?.removeAttribute("open"); if (button.classList.contains("edit-route")) { editRoute(route); } if (button.classList.contains("remove-route")) { removeRoute(route.id); } if (button.classList.contains("share-route")) { shareRoute(route); } } function startAddRoute() { resetForm(); formPanel.hidden = false; formTitle.textContent = "Add route"; saveButton.textContent = "Add route"; cancelEditButton.hidden = false; form.scrollIntoView({ behavior: "smooth", block: "start" }); nameInput.focus(); } function editRoute(route) { formPanel.hidden = false; formTitle.textContent = "Edit route"; idInput.value = route.id; nameInput.value = route.name; linkInput.value = route.link; dateInput.value = route.date; pictureInput.value = ""; descriptionInput.value = route.description; saveButton.textContent = "Save changes"; cancelEditButton.hidden = false; form.scrollIntoView({ behavior: "smooth", block: "start" }); nameInput.focus(); } function removeRoute(id) { const route = routes.find((item) => item.id === id); if (!route || !confirm(`Remove "${route.name}"?`)) { return; } routes = routes.filter((item) => item.id !== id); storeRoutes(); renderRoutes(); if (idInput.value === id) { resetForm(); } } function removeAllRoutes() { if (!routes.length || !confirm("Remove all saved routes?")) { return; } routes = []; storeRoutes(); renderRoutes(); resetForm(); } async function shareRoute(route) { const shareData = { title: route.name, text: route.description || route.name, url: route.link, }; if (navigator.share) { try { await navigator.share(shareData); return; } catch (error) { if (error.name === "AbortError") { return; } } } await navigator.clipboard.writeText(route.link); alert("Route link copied to clipboard."); } function resetForm() { form.reset(); idInput.value = ""; formTitle.textContent = "Add route"; saveButton.textContent = "Add route"; cancelEditButton.hidden = true; formPanel.hidden = true; } function createId() { return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`; } function imageFileToDataUrl(file) { if (!file.type.startsWith("image/")) { return Promise.reject(new Error("Not an image")); } return new Promise((resolve, reject) => { const image = new Image(); const objectUrl = URL.createObjectURL(file); image.onload = () => { URL.revokeObjectURL(objectUrl); const maxSize = 1200; const scale = Math.min(1, maxSize / Math.max(image.width, image.height)); const width = Math.round(image.width * scale); const height = Math.round(image.height * scale); const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d"); context.fillStyle = "#ffffff"; context.fillRect(0, 0, width, height); context.drawImage(image, 0, 0, width, height); resolve(canvas.toDataURL("image/jpeg", 0.82)); }; image.onerror = () => { URL.revokeObjectURL(objectUrl); reject(new Error("Image load failed")); }; image.src = objectUrl; }); } function isValidUrl(value) { try { const url = new URL(value); return url.protocol === "http:" || url.protocol === "https:"; } catch { return false; } } function formatDate(value) { return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(new Date(`${value}T00:00:00`)); } function cssUrlEscape(value) { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); }