Server backed saving routes
This commit is contained in:
parent
18de453ac1
commit
a4d6a8e99e
4 changed files with 102 additions and 25 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,2 +1,4 @@
|
|||
AGENTS.md
|
||||
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
|
||||
- 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.
|
||||
|
|
|
|||
115
app.js
115
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() {
|
||||
|
|
@ -279,7 +330,7 @@ function handleRouteAction(event) {
|
|||
}
|
||||
}
|
||||
|
||||
function toggleArchive(id) {
|
||||
async function toggleArchive(id) {
|
||||
const route = routes.find((item) => item.id === id);
|
||||
if (!route) {
|
||||
return;
|
||||
|
|
@ -287,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -320,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) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue