Compare commits

..

2 commits

Author SHA1 Message Date
a4d6a8e99e Server backed saving routes 2026-06-29 08:52:32 +00:00
18de453ac1 Images not cropping 2026-06-29 08:20:03 +00:00
6 changed files with 116 additions and 37 deletions

2
.gitignore vendored
View file

@ -1,2 +1,4 @@
AGENTS.md AGENTS.md
config.js config.js
data/routes.json
data/routes.json.tmp

View file

@ -8,10 +8,10 @@ A small static webpage for saving and sharing Google Maps route links.
- Optional date, description, and uploaded picture - Optional date, description, and uploaded picture
- Click route names to open saved Google Maps links - Click route names to open saved Google Maps links
- Edit, remove, share, or copy route 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 - 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 ## Secret
@ -25,4 +25,6 @@ cp config.example.js config.js
window.ROUTES_SECRET = "your-secret"; 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.

122
app.js
View file

@ -1,6 +1,7 @@
const STORAGE_KEY = "routes.links.v1"; const STORAGE_KEY = "routes.links.v1";
const AUTH_SESSION_KEY = "routes.authenticated.v1"; const AUTH_SESSION_KEY = "routes.authenticated.v1";
const AUTH_SECRET = window.ROUTES_SECRET; const AUTH_SECRET = window.ROUTES_SECRET;
const API_URL = window.ROUTES_API_URL || "api.php";
const authScreen = document.querySelector("#auth-screen"); const authScreen = document.querySelector("#auth-screen");
const authForm = document.querySelector("#auth-form"); const authForm = document.querySelector("#auth-form");
@ -29,7 +30,7 @@ const archiveCount = document.querySelector("#archive-count");
const archivedRoutesList = document.querySelector("#archived-routes-list"); const archivedRoutesList = document.querySelector("#archived-routes-list");
const template = document.querySelector("#route-card-template"); const template = document.querySelector("#route-card-template");
let routes = sortRoutes(loadRoutes()); let routes = [];
authForm.addEventListener("submit", authenticate); authForm.addEventListener("submit", authenticate);
logoutButton.addEventListener("click", lockApp); logoutButton.addEventListener("click", lockApp);
@ -68,12 +69,20 @@ function authenticate(event) {
unlockApp(); unlockApp();
} }
function unlockApp() { async function unlockApp() {
authScreen.hidden = true; authScreen.hidden = true;
appShell.hidden = false; appShell.hidden = false;
authSecretInput.value = ""; authSecretInput.value = "";
authMessage.textContent = ""; 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() { function lockApp() {
@ -84,7 +93,7 @@ function lockApp() {
authSecretInput.focus(); authSecretInput.focus();
} }
function loadRoutes() { function loadLocalRoutes() {
try { try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? []; return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
} catch { } 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)); 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) { function sortRoutes(routeList) {
@ -152,9 +198,14 @@ async function saveRoute(event) {
} }
routes = sortRoutes(routes); routes = sortRoutes(routes);
storeRoutes();
renderRoutes(); try {
resetForm(); await storeRoutes();
renderRoutes();
resetForm();
} catch (error) {
alert(error.message);
}
} }
function renderRoutes() { function renderRoutes() {
@ -189,6 +240,7 @@ function renderRoutes() {
function createRouteCard(route) { function createRouteCard(route) {
const card = template.content.firstElementChild.cloneNode(true); const card = template.content.firstElementChild.cloneNode(true);
const image = card.querySelector(".route-image"); const image = card.querySelector(".route-image");
const picture = card.querySelector(".route-picture");
const meta = card.querySelector(".route-meta"); const meta = card.querySelector(".route-meta");
const title = card.querySelector(".route-title"); const title = card.querySelector(".route-title");
const description = card.querySelector(".route-description"); const description = card.querySelector(".route-description");
@ -205,7 +257,7 @@ function createRouteCard(route) {
if (route.picture) { if (route.picture) {
image.hidden = false; image.hidden = false;
image.style.backgroundImage = `url("${cssUrlEscape(route.picture)}")`; picture.src = route.picture;
} }
return card; return card;
@ -278,7 +330,7 @@ function handleRouteAction(event) {
} }
} }
function toggleArchive(id) { async function toggleArchive(id) {
const route = routes.find((item) => item.id === id); const route = routes.find((item) => item.id === id);
if (!route) { if (!route) {
return; return;
@ -286,11 +338,17 @@ function toggleArchive(id) {
route.archived = !route.archived; route.archived = !route.archived;
routes = sortRoutes(routes); routes = sortRoutes(routes);
storeRoutes();
renderRoutes();
if (idInput.value === id) { try {
resetForm(); 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(); nameInput.focus();
} }
function removeRoute(id) { async function removeRoute(id) {
const route = routes.find((item) => item.id === id); const route = routes.find((item) => item.id === id);
if (!route || !confirm(`Remove "${route.name}"?`)) { if (!route || !confirm(`Remove "${route.name}"?`)) {
return; return;
} }
const previousRoutes = routes;
routes = routes.filter((item) => item.id !== id); routes = routes.filter((item) => item.id !== id);
storeRoutes();
renderRoutes(); try {
if (idInput.value === id) { await storeRoutes();
resetForm(); 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?")) { if (!routes.length || !confirm("Remove all saved routes?")) {
return; return;
} }
const previousRoutes = routes;
routes = []; routes = [];
storeRoutes();
renderRoutes(); try {
resetForm(); await storeRoutes();
renderRoutes();
resetForm();
} catch (error) {
routes = previousRoutes;
alert(error.message);
}
} }
async function shareRoute(route) { async function shareRoute(route) {
@ -428,7 +500,3 @@ function isValidUrl(value) {
function formatDate(value) { function formatDate(value) {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(new Date(`${value}T00:00:00`)); return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(new Date(`${value}T00:00:00`));
} }
function cssUrlEscape(value) {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}

View file

@ -1 +1,3 @@
window.ROUTES_SECRET = "change-this-secret"; 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";

View file

@ -94,7 +94,7 @@
<template id="route-card-template"> <template id="route-card-template">
<article class="route-card"> <article class="route-card">
<div class="route-image" aria-hidden="true" hidden></div> <div class="route-image" aria-hidden="true" hidden><img class="route-picture" alt="" /></div>
<div class="route-content"> <div class="route-content">
<div class="route-meta"></div> <div class="route-meta"></div>
<div class="route-top"> <div class="route-top">

View file

@ -231,11 +231,16 @@ textarea:focus {
} }
.route-image { .route-image {
min-height: 130px; display: flex;
background-color: color-mix(in srgb, var(--border) 45%, transparent); align-items: center;
background-position: center; justify-content: center;
background-repeat: no-repeat; background: color-mix(in srgb, var(--border) 45%, transparent);
background-size: contain; }
.route-picture {
display: block;
width: 100%;
height: auto;
} }
.route-content { .route-content {
@ -398,7 +403,7 @@ textarea:focus {
} }
.route-image { .route-image {
min-height: 100%; align-self: start;
} }
} }