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
config.js

View file

@ -5,8 +5,8 @@ A small static webpage for saving and sharing Google Maps route links.
## Features
- Add routes with name and Google Maps link (mandatory)
- Optional date, description, and picture URL
- Click route names or **Open map** to open saved Google Maps 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`
- Simple secret gate before the app opens
@ -15,4 +15,14 @@ Open `index.html` in a browser to use it.
## 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 AUTH_SESSION_KEY = "routes.authenticated.v1";
// Simple shared secret. Change this before publishing if needed.
const AUTH_SECRET = "change-me";
const AUTH_SECRET = window.ROUTES_SECRET;
const authScreen = document.querySelector("#auth-screen");
const authForm = document.querySelector("#auth-form");
@ -47,6 +46,11 @@ function authenticate(event) {
event.preventDefault();
authMessage.textContent = "";
if (!AUTH_SECRET) {
authMessage.textContent = "Missing config.js secret.";
return;
}
if (authSecretInput.value.trim() !== AUTH_SECRET) {
authMessage.textContent = "Wrong secret.";
authSecretInput.select();
@ -85,15 +89,19 @@ function storeRoutes() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(routes));
}
function saveRoute(event) {
async function saveRoute(event) {
event.preventDefault();
const id = idInput.value || createId();
const existingIndex = routes.findIndex((item) => item.id === id);
const existingRoute = routes[existingIndex];
const route = {
id: idInput.value || createId(),
id,
name: nameInput.value.trim(),
link: linkInput.value.trim(),
date: dateInput.value,
picture: pictureInput.value.trim(),
picture: existingRoute?.picture || "",
description: descriptionInput.value.trim(),
updatedAt: new Date().toISOString(),
};
@ -102,12 +110,20 @@ function saveRoute(event) {
return;
}
if (!isValidUrl(route.link) || (route.picture && !isValidUrl(route.picture))) {
alert("Please enter valid URLs for the map link and picture.");
if (!isValidUrl(route.link)) {
alert("Please enter a valid Google Maps link.");
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) {
routes[existingIndex] = route;
} else {
@ -134,14 +150,12 @@ function renderRoutes() {
const meta = card.querySelector(".route-meta");
const title = card.querySelector(".route-title");
const description = card.querySelector(".route-description");
const openLink = card.querySelector(".open-link");
card.dataset.id = route.id;
meta.textContent = route.date ? formatDate(route.date) : "";
meta.hidden = !route.date;
title.textContent = route.name;
title.href = route.link;
openLink.href = route.link;
description.textContent = route.description;
description.hidden = !route.description;
@ -166,6 +180,8 @@ function handleRouteAction(event) {
return;
}
button.closest("details")?.removeAttribute("open");
if (button.classList.contains("edit-route")) {
editRoute(route);
}
@ -196,7 +212,7 @@ function editRoute(route) {
nameInput.value = route.name;
linkInput.value = route.link;
dateInput.value = route.date;
pictureInput.value = route.picture;
pictureInput.value = "";
descriptionInput.value = route.description;
saveButton.textContent = "Save changes";
cancelEditButton.hidden = false;
@ -264,6 +280,43 @@ function createId() {
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) {
try {
const url = new URL(value);

View file

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

View file

@ -129,7 +129,6 @@ h3 {
.header-actions,
.form-actions,
.route-actions,
.section-heading {
display: flex;
align-items: center;
@ -137,7 +136,6 @@ h3 {
}
.header-actions,
.route-actions,
.form-actions {
flex-wrap: wrap;
justify-content: flex-end;
@ -175,8 +173,7 @@ textarea {
resize: vertical;
}
button,
.open-link {
button {
border: 0;
border-radius: 999px;
padding: 0.7rem 0.95rem;
@ -185,12 +182,10 @@ button,
font: inherit;
font-weight: 800;
line-height: 1;
text-decoration: none;
cursor: pointer;
}
button:hover,
.open-link:hover {
button:hover {
background: var(--primary-dark);
}
@ -246,6 +241,17 @@ textarea:focus {
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 {
color: var(--text);
text-decoration: none;
@ -257,10 +263,74 @@ textarea:focus {
}
.route-description {
margin-bottom: 0.85rem;
margin-bottom: 0;
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 {
padding: 1.5rem 1rem;
text-align: center;
@ -283,15 +353,6 @@ textarea:focus {
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) {