diff --git a/.gitignore b/.gitignore index 44f8308..dd616b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ AGENTS.md config.js +data/routes.json +data/routes.json.tmp diff --git a/README.md b/README.md index ee46abe..3166c5d 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,10 @@ A small static webpage for saving and sharing Google Maps route links. - Optional date, description, and uploaded picture - Click route names to open saved Google Maps links - Edit, remove, share, or copy route links -- Data is stored locally in the browser with `localStorage` +- Routes are stored on the webserver in `data/routes.json` - Simple secret gate before the app opens -Open `index.html` in a browser to use it. +Deploy the files to a PHP-enabled webserver and open `index.html` in a browser. ## Secret @@ -25,4 +25,6 @@ cp config.example.js config.js window.ROUTES_SECRET = "your-secret"; ``` -`config.js` is gitignored. This is a simple client-side gate, not strong server-side security. +`config.js` is gitignored. `api.php` reads the same secret and stores shared routes in `data/routes.json`, which is also gitignored. + +Make sure the webserver user can write to the `data/` directory. This is a simple shared-secret gate, not strong security. diff --git a/app.js b/app.js index 7266d35..c0cab44 100644 --- a/app.js +++ b/app.js @@ -1,6 +1,7 @@ const STORAGE_KEY = "routes.links.v1"; const AUTH_SESSION_KEY = "routes.authenticated.v1"; const AUTH_SECRET = window.ROUTES_SECRET; +const API_URL = window.ROUTES_API_URL || "api.php"; const authScreen = document.querySelector("#auth-screen"); const authForm = document.querySelector("#auth-form"); @@ -29,7 +30,7 @@ const archiveCount = document.querySelector("#archive-count"); const archivedRoutesList = document.querySelector("#archived-routes-list"); const template = document.querySelector("#route-card-template"); -let routes = sortRoutes(loadRoutes()); +let routes = []; authForm.addEventListener("submit", authenticate); logoutButton.addEventListener("click", lockApp); @@ -68,12 +69,20 @@ function authenticate(event) { unlockApp(); } -function unlockApp() { +async function unlockApp() { authScreen.hidden = true; appShell.hidden = false; authSecretInput.value = ""; authMessage.textContent = ""; - renderRoutes(); + routeCount.textContent = "Loading routes..."; + + try { + routes = await loadRoutesFromBackend(); + renderRoutes(); + } catch (error) { + routeCount.textContent = "Could not load routes."; + alert(error.message); + } } function lockApp() { @@ -84,7 +93,7 @@ function lockApp() { authSecretInput.focus(); } -function loadRoutes() { +function loadLocalRoutes() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? []; } catch { @@ -92,8 +101,45 @@ function loadRoutes() { } } -function storeRoutes() { +async function loadRoutesFromBackend() { + const response = await fetch(API_URL, { + headers: { "X-Routes-Secret": AUTH_SECRET }, + cache: "no-store", + }); + + if (!response.ok) { + throw new Error(`Could not load routes (${response.status}).`); + } + + const data = await response.json(); + const backendRoutes = sortRoutes(data.routes ?? []); + const localRoutes = sortRoutes(loadLocalRoutes()); + + if (!backendRoutes.length && localRoutes.length) { + routes = localRoutes; + await storeRoutes(); + return localRoutes; + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(backendRoutes)); + return backendRoutes; +} + +async function storeRoutes() { localStorage.setItem(STORAGE_KEY, JSON.stringify(routes)); + + const response = await fetch(API_URL, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "X-Routes-Secret": AUTH_SECRET, + }, + body: JSON.stringify({ routes }), + }); + + if (!response.ok) { + throw new Error(`Could not save routes (${response.status}).`); + } } function sortRoutes(routeList) { @@ -152,9 +198,14 @@ async function saveRoute(event) { } routes = sortRoutes(routes); - storeRoutes(); - renderRoutes(); - resetForm(); + + try { + await storeRoutes(); + renderRoutes(); + resetForm(); + } catch (error) { + alert(error.message); + } } function renderRoutes() { @@ -189,6 +240,7 @@ function renderRoutes() { function createRouteCard(route) { const card = template.content.firstElementChild.cloneNode(true); const image = card.querySelector(".route-image"); + const picture = card.querySelector(".route-picture"); const meta = card.querySelector(".route-meta"); const title = card.querySelector(".route-title"); const description = card.querySelector(".route-description"); @@ -205,7 +257,7 @@ function createRouteCard(route) { if (route.picture) { image.hidden = false; - image.style.backgroundImage = `url("${cssUrlEscape(route.picture)}")`; + picture.src = route.picture; } return card; @@ -278,7 +330,7 @@ function handleRouteAction(event) { } } -function toggleArchive(id) { +async function toggleArchive(id) { const route = routes.find((item) => item.id === id); if (!route) { return; @@ -286,11 +338,17 @@ function toggleArchive(id) { route.archived = !route.archived; routes = sortRoutes(routes); - storeRoutes(); - renderRoutes(); - if (idInput.value === id) { - resetForm(); + try { + await storeRoutes(); + renderRoutes(); + + if (idInput.value === id) { + resetForm(); + } + } catch (error) { + route.archived = !route.archived; + alert(error.message); } } @@ -319,29 +377,43 @@ function editRoute(route) { nameInput.focus(); } -function removeRoute(id) { +async function removeRoute(id) { const route = routes.find((item) => item.id === id); if (!route || !confirm(`Remove "${route.name}"?`)) { return; } + const previousRoutes = routes; routes = routes.filter((item) => item.id !== id); - storeRoutes(); - renderRoutes(); - if (idInput.value === id) { - resetForm(); + + try { + await storeRoutes(); + renderRoutes(); + if (idInput.value === id) { + resetForm(); + } + } catch (error) { + routes = previousRoutes; + alert(error.message); } } -function removeAllRoutes() { +async function removeAllRoutes() { if (!routes.length || !confirm("Remove all saved routes?")) { return; } + const previousRoutes = routes; routes = []; - storeRoutes(); - renderRoutes(); - resetForm(); + + try { + await storeRoutes(); + renderRoutes(); + resetForm(); + } catch (error) { + routes = previousRoutes; + alert(error.message); + } } async function shareRoute(route) { @@ -428,7 +500,3 @@ function isValidUrl(value) { function formatDate(value) { return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(new Date(`${value}T00:00:00`)); } - -function cssUrlEscape(value) { - return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); -} diff --git a/config.example.js b/config.example.js index 7b4a660..f2e6439 100644 --- a/config.example.js +++ b/config.example.js @@ -1 +1,3 @@ window.ROUTES_SECRET = "change-this-secret"; +// Optional if api.php is not in the same directory as index.html: +// window.ROUTES_API_URL = "api.php"; diff --git a/index.html b/index.html index 6d4ce57..34d4a78 100644 --- a/index.html +++ b/index.html @@ -94,7 +94,7 @@