Secred in separate file, desing improvements

This commit is contained in:
marijo 2026-06-29 07:58:28 +00:00
parent 89b2a7171e
commit 33c3eedb3a
5 changed files with 171 additions and 40 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
AGENTS.md AGENTS.md
config.js

View file

@ -5,8 +5,8 @@ A small static webpage for saving and sharing Google Maps route links.
## Features ## Features
- Add routes with name and Google Maps link (mandatory) - Add routes with name and Google Maps link (mandatory)
- Optional date, description, and picture URL - Optional date, description, and uploaded picture
- Click route names or **Open map** 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` - Data is stored locally in the browser with `localStorage`
- Simple secret gate before the app opens - Simple secret gate before the app opens
@ -15,4 +15,14 @@ Open `index.html` in a browser to use it.
## Secret ## Secret
The current demo secret is `change-me`. Before publishing, replace `AUTH_SECRET` in `app.js` with your real secret. This is a simple client-side gate, not strong server-side security. Create `config.js` from the example and set your secret there:
```sh
cp config.example.js config.js
```
```js
window.ROUTES_SECRET = "your-secret";
```
`config.js` is gitignored. This is a simple client-side gate, not strong server-side security.

75
app.js
View file

@ -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";
// Simple shared secret. Change this before publishing if needed. const AUTH_SECRET = window.ROUTES_SECRET;
const AUTH_SECRET = "change-me";
const authScreen = document.querySelector("#auth-screen"); const authScreen = document.querySelector("#auth-screen");
const authForm = document.querySelector("#auth-form"); const authForm = document.querySelector("#auth-form");
@ -47,6 +46,11 @@ function authenticate(event) {
event.preventDefault(); event.preventDefault();
authMessage.textContent = ""; authMessage.textContent = "";
if (!AUTH_SECRET) {
authMessage.textContent = "Missing config.js secret.";
return;
}
if (authSecretInput.value.trim() !== AUTH_SECRET) { if (authSecretInput.value.trim() !== AUTH_SECRET) {
authMessage.textContent = "Wrong secret."; authMessage.textContent = "Wrong secret.";
authSecretInput.select(); authSecretInput.select();
@ -85,15 +89,19 @@ function storeRoutes() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(routes)); localStorage.setItem(STORAGE_KEY, JSON.stringify(routes));
} }
function saveRoute(event) { async function saveRoute(event) {
event.preventDefault(); event.preventDefault();
const id = idInput.value || createId();
const existingIndex = routes.findIndex((item) => item.id === id);
const existingRoute = routes[existingIndex];
const route = { const route = {
id: idInput.value || createId(), id,
name: nameInput.value.trim(), name: nameInput.value.trim(),
link: linkInput.value.trim(), link: linkInput.value.trim(),
date: dateInput.value, date: dateInput.value,
picture: pictureInput.value.trim(), picture: existingRoute?.picture || "",
description: descriptionInput.value.trim(), description: descriptionInput.value.trim(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
@ -102,12 +110,20 @@ function saveRoute(event) {
return; return;
} }
if (!isValidUrl(route.link) || (route.picture && !isValidUrl(route.picture))) { if (!isValidUrl(route.link)) {
alert("Please enter valid URLs for the map link and picture."); alert("Please enter a valid Google Maps link.");
return; return;
} }
const existingIndex = routes.findIndex((item) => item.id === route.id); 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) { if (existingIndex >= 0) {
routes[existingIndex] = route; routes[existingIndex] = route;
} else { } else {
@ -134,14 +150,12 @@ function renderRoutes() {
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");
const openLink = card.querySelector(".open-link");
card.dataset.id = route.id; card.dataset.id = route.id;
meta.textContent = route.date ? formatDate(route.date) : ""; meta.textContent = route.date ? formatDate(route.date) : "";
meta.hidden = !route.date; meta.hidden = !route.date;
title.textContent = route.name; title.textContent = route.name;
title.href = route.link; title.href = route.link;
openLink.href = route.link;
description.textContent = route.description; description.textContent = route.description;
description.hidden = !route.description; description.hidden = !route.description;
@ -166,6 +180,8 @@ function handleRouteAction(event) {
return; return;
} }
button.closest("details")?.removeAttribute("open");
if (button.classList.contains("edit-route")) { if (button.classList.contains("edit-route")) {
editRoute(route); editRoute(route);
} }
@ -196,7 +212,7 @@ function editRoute(route) {
nameInput.value = route.name; nameInput.value = route.name;
linkInput.value = route.link; linkInput.value = route.link;
dateInput.value = route.date; dateInput.value = route.date;
pictureInput.value = route.picture; pictureInput.value = "";
descriptionInput.value = route.description; descriptionInput.value = route.description;
saveButton.textContent = "Save changes"; saveButton.textContent = "Save changes";
cancelEditButton.hidden = false; cancelEditButton.hidden = false;
@ -264,6 +280,43 @@ function createId() {
return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`; 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) { function isValidUrl(value) {
try { try {
const url = new URL(value); const url = new URL(value);

View file

@ -4,6 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Routes</title> <title>Routes</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml" />
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<body> <body>
@ -52,8 +53,8 @@
</label> </label>
<label> <label>
Picture URL Picture
<input id="route-picture" name="picture" type="url" placeholder="https://example.com/photo.jpg" /> <input id="route-picture" name="picture" type="file" accept="image/*" />
</label> </label>
<label> <label>
@ -91,18 +92,23 @@
<div class="route-image" aria-hidden="true" hidden></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>
<h3><a class="route-title" target="_blank" rel="noopener noreferrer"></a></h3> <div class="route-top">
<p class="route-description"></p> <h3><a class="route-title" target="_blank" rel="noopener noreferrer"></a></h3>
<div class="route-actions"> <details class="route-menu">
<a class="open-link" target="_blank" rel="noopener noreferrer">Open map</a> <summary aria-label="Route actions"></summary>
<button type="button" class="secondary share-route">Share</button> <div class="menu-items">
<button type="button" class="secondary edit-route">Edit</button> <button type="button" class="share-route">Share</button>
<button type="button" class="secondary danger remove-route">Remove</button> <button type="button" class="edit-route">Edit</button>
<button type="button" class="danger remove-route">Delete</button>
</div>
</details>
</div> </div>
<p class="route-description"></p>
</div> </div>
</article> </article>
</template> </template>
<script src="config.js"></script>
<script src="app.js" defer></script> <script src="app.js" defer></script>
</body> </body>
</html> </html>

View file

@ -129,7 +129,6 @@ h3 {
.header-actions, .header-actions,
.form-actions, .form-actions,
.route-actions,
.section-heading { .section-heading {
display: flex; display: flex;
align-items: center; align-items: center;
@ -137,7 +136,6 @@ h3 {
} }
.header-actions, .header-actions,
.route-actions,
.form-actions { .form-actions {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
@ -175,8 +173,7 @@ textarea {
resize: vertical; resize: vertical;
} }
button, button {
.open-link {
border: 0; border: 0;
border-radius: 999px; border-radius: 999px;
padding: 0.7rem 0.95rem; padding: 0.7rem 0.95rem;
@ -185,12 +182,10 @@ button,
font: inherit; font: inherit;
font-weight: 800; font-weight: 800;
line-height: 1; line-height: 1;
text-decoration: none;
cursor: pointer; cursor: pointer;
} }
button:hover, button:hover {
.open-link:hover {
background: var(--primary-dark); background: var(--primary-dark);
} }
@ -246,6 +241,17 @@ textarea:focus {
font-weight: 700; font-weight: 700;
} }
.route-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.route-top h3 {
flex: 1;
}
.route-title { .route-title {
color: var(--text); color: var(--text);
text-decoration: none; text-decoration: none;
@ -257,10 +263,74 @@ textarea:focus {
} }
.route-description { .route-description {
margin-bottom: 0.85rem; margin-bottom: 0;
white-space: pre-wrap; white-space: pre-wrap;
} }
.route-menu {
position: relative;
flex: none;
}
.route-menu summary {
display: grid;
width: 2.2rem;
height: 2.2rem;
place-items: center;
border-radius: 999px;
color: var(--muted);
cursor: pointer;
font-size: 1.4rem;
line-height: 1;
list-style: none;
}
.route-menu summary::-webkit-details-marker {
display: none;
}
.route-menu summary:hover,
.route-menu[open] summary {
background: color-mix(in srgb, var(--primary) 10%, transparent);
color: var(--primary);
}
.menu-items {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
z-index: 3;
min-width: 9rem;
padding: 0.35rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.8rem;
box-shadow: var(--shadow);
}
.menu-items button {
display: block;
width: 100%;
border-radius: 0.55rem;
padding: 0.75rem;
background: transparent;
color: var(--text);
text-align: left;
}
.menu-items button:hover {
background: color-mix(in srgb, var(--primary) 10%, transparent);
}
.menu-items .danger {
color: var(--danger);
}
.menu-items .danger:hover {
color: #fff;
background: var(--danger-dark);
}
.empty-state { .empty-state {
padding: 1.5rem 1rem; padding: 1.5rem 1rem;
text-align: center; text-align: center;
@ -283,15 +353,6 @@ textarea:focus {
padding-inline: 0.8rem; padding-inline: 0.8rem;
} }
.route-actions {
display: grid;
grid-template-columns: 1fr 1fr;
justify-content: stretch;
}
.route-actions > * {
text-align: center;
}
} }
@media (min-width: 700px) { @media (min-width: 700px) {