Compare commits
No commits in common. "a4d6a8e99e3b6c402550280f0e542d994adcbe03" and "c021498808c9b3f50404cc1cce5fdc4508001f1a" have entirely different histories.
a4d6a8e99e
...
c021498808
6 changed files with 37 additions and 116 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,4 +1,2 @@
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
config.js
|
config.js
|
||||||
data/routes.json
|
|
||||||
data/routes.json.tmp
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
- Routes are stored on the webserver in `data/routes.json`
|
- Data is stored locally in the browser with `localStorage`
|
||||||
- Simple secret gate before the app opens
|
- Simple secret gate before the app opens
|
||||||
|
|
||||||
Deploy the files to a PHP-enabled webserver and open `index.html` in a browser.
|
Open `index.html` in a browser to use it.
|
||||||
|
|
||||||
## Secret
|
## Secret
|
||||||
|
|
||||||
|
|
@ -25,6 +25,4 @@ cp config.example.js config.js
|
||||||
window.ROUTES_SECRET = "your-secret";
|
window.ROUTES_SECRET = "your-secret";
|
||||||
```
|
```
|
||||||
|
|
||||||
`config.js` is gitignored. `api.php` reads the same secret and stores shared routes in `data/routes.json`, which is also gitignored.
|
`config.js` is gitignored. This is a simple client-side gate, not strong server-side security.
|
||||||
|
|
||||||
Make sure the webserver user can write to the `data/` directory. This is a simple shared-secret gate, not strong security.
|
|
||||||
|
|
|
||||||
122
app.js
122
app.js
|
|
@ -1,7 +1,6 @@
|
||||||
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");
|
||||||
|
|
@ -30,7 +29,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 = [];
|
let routes = sortRoutes(loadRoutes());
|
||||||
|
|
||||||
authForm.addEventListener("submit", authenticate);
|
authForm.addEventListener("submit", authenticate);
|
||||||
logoutButton.addEventListener("click", lockApp);
|
logoutButton.addEventListener("click", lockApp);
|
||||||
|
|
@ -69,20 +68,12 @@ function authenticate(event) {
|
||||||
unlockApp();
|
unlockApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unlockApp() {
|
function unlockApp() {
|
||||||
authScreen.hidden = true;
|
authScreen.hidden = true;
|
||||||
appShell.hidden = false;
|
appShell.hidden = false;
|
||||||
authSecretInput.value = "";
|
authSecretInput.value = "";
|
||||||
authMessage.textContent = "";
|
authMessage.textContent = "";
|
||||||
routeCount.textContent = "Loading routes...";
|
renderRoutes();
|
||||||
|
|
||||||
try {
|
|
||||||
routes = await loadRoutesFromBackend();
|
|
||||||
renderRoutes();
|
|
||||||
} catch (error) {
|
|
||||||
routeCount.textContent = "Could not load routes.";
|
|
||||||
alert(error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function lockApp() {
|
function lockApp() {
|
||||||
|
|
@ -93,7 +84,7 @@ function lockApp() {
|
||||||
authSecretInput.focus();
|
authSecretInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadLocalRoutes() {
|
function loadRoutes() {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
|
return JSON.parse(localStorage.getItem(STORAGE_KEY)) ?? [];
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -101,45 +92,8 @@ function loadLocalRoutes() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRoutesFromBackend() {
|
function storeRoutes() {
|
||||||
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) {
|
||||||
|
|
@ -198,14 +152,9 @@ async function saveRoute(event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
routes = sortRoutes(routes);
|
routes = sortRoutes(routes);
|
||||||
|
storeRoutes();
|
||||||
try {
|
renderRoutes();
|
||||||
await storeRoutes();
|
resetForm();
|
||||||
renderRoutes();
|
|
||||||
resetForm();
|
|
||||||
} catch (error) {
|
|
||||||
alert(error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRoutes() {
|
function renderRoutes() {
|
||||||
|
|
@ -240,7 +189,6 @@ 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");
|
||||||
|
|
@ -257,7 +205,7 @@ function createRouteCard(route) {
|
||||||
|
|
||||||
if (route.picture) {
|
if (route.picture) {
|
||||||
image.hidden = false;
|
image.hidden = false;
|
||||||
picture.src = route.picture;
|
image.style.backgroundImage = `url("${cssUrlEscape(route.picture)}")`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
|
|
@ -330,7 +278,7 @@ function handleRouteAction(event) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleArchive(id) {
|
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;
|
||||||
|
|
@ -338,17 +286,11 @@ async function toggleArchive(id) {
|
||||||
|
|
||||||
route.archived = !route.archived;
|
route.archived = !route.archived;
|
||||||
routes = sortRoutes(routes);
|
routes = sortRoutes(routes);
|
||||||
|
storeRoutes();
|
||||||
|
renderRoutes();
|
||||||
|
|
||||||
try {
|
if (idInput.value === id) {
|
||||||
await storeRoutes();
|
resetForm();
|
||||||
renderRoutes();
|
|
||||||
|
|
||||||
if (idInput.value === id) {
|
|
||||||
resetForm();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
route.archived = !route.archived;
|
|
||||||
alert(error.message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -377,43 +319,29 @@ function editRoute(route) {
|
||||||
nameInput.focus();
|
nameInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeRoute(id) {
|
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();
|
||||||
try {
|
renderRoutes();
|
||||||
await storeRoutes();
|
if (idInput.value === id) {
|
||||||
renderRoutes();
|
resetForm();
|
||||||
if (idInput.value === id) {
|
|
||||||
resetForm();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
routes = previousRoutes;
|
|
||||||
alert(error.message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeAllRoutes() {
|
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();
|
||||||
try {
|
renderRoutes();
|
||||||
await storeRoutes();
|
resetForm();
|
||||||
renderRoutes();
|
|
||||||
resetForm();
|
|
||||||
} catch (error) {
|
|
||||||
routes = previousRoutes;
|
|
||||||
alert(error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shareRoute(route) {
|
async function shareRoute(route) {
|
||||||
|
|
@ -500,3 +428,7 @@ 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('"', '\\"');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1 @@
|
||||||
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";
|
|
||||||
|
|
|
||||||
|
|
@ -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><img class="route-picture" alt="" /></div>
|
<div class="route-image" aria-hidden="true" hidden></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">
|
||||||
|
|
|
||||||
17
styles.css
17
styles.css
|
|
@ -231,16 +231,11 @@ textarea:focus {
|
||||||
}
|
}
|
||||||
|
|
||||||
.route-image {
|
.route-image {
|
||||||
display: flex;
|
min-height: 130px;
|
||||||
align-items: center;
|
background-color: color-mix(in srgb, var(--border) 45%, transparent);
|
||||||
justify-content: center;
|
background-position: center;
|
||||||
background: color-mix(in srgb, var(--border) 45%, transparent);
|
background-repeat: no-repeat;
|
||||||
}
|
background-size: contain;
|
||||||
|
|
||||||
.route-picture {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.route-content {
|
.route-content {
|
||||||
|
|
@ -403,7 +398,7 @@ textarea:focus {
|
||||||
}
|
}
|
||||||
|
|
||||||
.route-image {
|
.route-image {
|
||||||
align-self: start;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue