diff --git a/server/main.go b/server/main.go
index 1951b0c..dafb505 100644
--- a/server/main.go
+++ b/server/main.go
@@ -28,7 +28,7 @@ type Event struct {
Type string `json:"type"`
At int64 `json:"at"`
Note string `json:"note"`
- PhotoID string `json:"photoId,omitempty"`
+ PhotoID string `json:"photoId,omitempty"` // photo UUIDs, comma-separated (legacy events hold one)
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
diff --git a/src/app.js b/src/app.js
index 400b62a..231e70d 100644
--- a/src/app.js
+++ b/src/app.js
@@ -779,18 +779,18 @@
}
li.addEventListener("click", () => openEditDialog(ev));
- if (ev.photoId) {
+ for (const pid of photoIdsOf(ev)) {
const img = document.createElement("img");
img.className = "thumb";
img.alt = "photo";
img.loading = "lazy";
- img.dataset.photoId = ev.photoId;
+ img.dataset.photoId = pid;
img.addEventListener("click", (e) => {
e.stopPropagation(); // don't open the edit dialog
- openLightbox(ev.photoId);
+ openLightbox(pid);
});
li.appendChild(img);
- photoSrc(ev.photoId).then(url => { if (url) img.src = url; });
+ photoSrc(pid).then(url => { if (url) img.src = url; });
}
eventList.appendChild(li);
@@ -1695,27 +1695,60 @@
const noteTitle = document.getElementById("note-title");
const notePhotoInput = document.getElementById("note-photo-input");
const notePhotoBtn = document.getElementById("note-photo-btn");
- const notePhotoClear = document.getElementById("note-photo-clear");
const notePhotoPreview = document.getElementById("note-photo-preview");
const noteWeightField = document.getElementById("note-weight-field");
const noteWeight = document.getElementById("note-weight");
const noteGramsField = document.getElementById("note-grams-field");
const noteGrams = document.getElementById("note-grams");
let pendingType = null;
- let notePhotoBlob = null; // pending blob for the dialog (not yet committed)
- let notePhotoURL = null; // current preview object URL
+ let notePhotos = []; // pending photos for this dialog: [{ blob, url }]
// Whether the user has manually touched the date/time fields. While false the
// dialog logs at the exact current millisecond rather than the minute-floored
// input, so a just-logged event doesn't look up to ~59s old.
let noteTimeEdited = false;
- function clearNotePhoto() {
- notePhotoBlob = null;
- if (notePhotoURL) { URL.revokeObjectURL(notePhotoURL); notePhotoURL = null; }
- notePhotoPreview.hidden = true;
+ // An event can carry several photos: photoId holds their UUIDs
+ // comma-separated. A legacy single id is just a one-element list, and the
+ // server passes the string through untouched (photos themselves are
+ // uploaded and fetched individually by UUID).
+ function photoIdsOf(ev) {
+ return (ev.photoId || "").split(",").filter(Boolean);
+ }
+
+ // A preview thumbnail with a remove button; shared by both dialogs.
+ function photoThumb(url, onRemove) {
+ const wrap = document.createElement("div");
+ wrap.className = "photo-thumb";
+ const img = document.createElement("img");
+ img.alt = "";
+ if (url) img.src = url;
+ const rm = document.createElement("button");
+ rm.type = "button";
+ rm.className = "photo-thumb-remove";
+ rm.setAttribute("aria-label", "Remove photo");
+ rm.textContent = "×";
+ rm.addEventListener("click", onRemove);
+ wrap.appendChild(img);
+ wrap.appendChild(rm);
+ return wrap;
+ }
+
+ function renderNotePhotos() {
notePhotoPreview.innerHTML = "";
- notePhotoClear.hidden = true;
- notePhotoBtn.textContent = "📷 Add photo";
+ notePhotoPreview.hidden = notePhotos.length === 0;
+ notePhotos.forEach((p, i) => {
+ notePhotoPreview.appendChild(photoThumb(p.url, () => {
+ URL.revokeObjectURL(p.url);
+ notePhotos.splice(i, 1);
+ renderNotePhotos();
+ }));
+ });
+ }
+
+ function clearNotePhotos() {
+ for (const p of notePhotos) URL.revokeObjectURL(p.url);
+ notePhotos = [];
+ renderNotePhotos();
notePhotoInput.value = "";
}
@@ -1733,7 +1766,7 @@
noteWeight.value = "";
noteGramsField.hidden = !isEat;
noteGrams.value = "";
- clearNotePhoto();
+ clearNotePhotos();
noteDialog.showModal();
setTimeout(() => (isWeight ? noteWeight : isEat ? noteGrams : noteInput).focus(), 50);
}
@@ -1760,21 +1793,17 @@
});
notePhotoBtn.addEventListener("click", () => notePhotoInput.click());
- notePhotoClear.addEventListener("click", () => clearNotePhoto());
notePhotoInput.addEventListener("change", async (e) => {
- const file = e.target.files?.[0];
- if (!file) return;
- try {
- notePhotoBlob = await resizeImage(file);
- if (notePhotoURL) URL.revokeObjectURL(notePhotoURL);
- notePhotoURL = URL.createObjectURL(notePhotoBlob);
- notePhotoPreview.innerHTML = ``;
- notePhotoPreview.hidden = false;
- notePhotoClear.hidden = false;
- notePhotoBtn.textContent = "📷 Replace photo";
- } catch (err) {
- alert("Couldn't process that photo: " + err.message);
+ for (const file of Array.from(e.target.files || [])) {
+ try {
+ const blob = await resizeImage(file);
+ notePhotos.push({ blob, url: URL.createObjectURL(blob) });
+ } catch (err) {
+ alert("Couldn't process that photo: " + err.message);
+ }
}
+ notePhotoInput.value = "";
+ renderNotePhotos();
});
document.getElementById("note-save").addEventListener("click", async (e) => {
@@ -1792,24 +1821,25 @@
if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; }
grams = Math.round(g);
}
- let photoId = "";
- if (notePhotoBlob) {
- photoId = uuid();
- try { await putPhoto(photoId, notePhotoBlob, false); }
+ const photoIds = [];
+ for (const p of notePhotos) {
+ const id = uuid();
+ try { await putPhoto(id, p.blob, false); }
catch (err) {
alert("Couldn't store photo locally: " + err.message);
return;
}
+ photoIds.push(id);
}
- addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId, weight, grams });
+ addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId: photoIds.join(","), weight, grams });
pendingType = null;
- clearNotePhoto();
+ clearNotePhotos();
noteDialog.close();
});
noteForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => {
e.preventDefault();
pendingType = null;
- clearNotePhoto();
+ clearNotePhotos();
noteDialog.close();
});
@@ -1822,27 +1852,32 @@
const editDelete = document.getElementById("edit-delete");
const editPhotoInput = document.getElementById("edit-photo-input");
const editPhotoBtn = document.getElementById("edit-photo-btn");
- const editPhotoClear = document.getElementById("edit-photo-clear");
const editPhotoPreview = document.getElementById("edit-photo-preview");
const editWeightField = document.getElementById("edit-weight-field");
const editWeight = document.getElementById("edit-weight");
const editGramsField = document.getElementById("edit-grams-field");
const editGrams = document.getElementById("edit-grams");
let editingId = null;
- let editPhotoId = ""; // current photoId for this event
- let editPhotoBlob = null; // new blob chosen in this session
- let editPhotoURL = null;
- let editPhotoCleared = false; // user removed an existing photo
+ // The dialog's working set of photos, in display order. Existing photos are
+ // { id, url } (url from photoSrc's page-lifetime cache — never revoked here);
+ // newly picked ones are { blob, url } with a fresh object URL we own.
+ let editPhotos = [];
- function setEditPreviewFromURL(url) {
- if (!url) { editPhotoPreview.hidden = true; editPhotoPreview.innerHTML = ""; return; }
- editPhotoPreview.innerHTML = `
`;
- editPhotoPreview.hidden = false;
+ function renderEditPhotos() {
+ editPhotoPreview.innerHTML = "";
+ editPhotoPreview.hidden = editPhotos.length === 0;
+ editPhotos.forEach((p, i) => {
+ editPhotoPreview.appendChild(photoThumb(p.url, () => {
+ if (p.blob) URL.revokeObjectURL(p.url);
+ editPhotos.splice(i, 1);
+ renderEditPhotos();
+ }));
+ });
}
- function clearEditPhotoLocalState() {
- editPhotoBlob = null;
- if (editPhotoURL) { URL.revokeObjectURL(editPhotoURL); editPhotoURL = null; }
+ function resetEditPhotos() {
+ for (const p of editPhotos) if (p.blob) URL.revokeObjectURL(p.url);
+ editPhotos = [];
editPhotoInput.value = "";
}
@@ -1855,44 +1890,26 @@
editWeight.value = (ev.type === "weight" && Number.isFinite(ev.weight)) ? ev.weight : "";
editGramsField.hidden = ev.type !== "eat";
editGrams.value = (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) ? ev.grams : "";
- editPhotoId = ev.photoId || "";
- editPhotoCleared = false;
- clearEditPhotoLocalState();
- if (editPhotoId) {
- const url = await photoSrc(editPhotoId);
- setEditPreviewFromURL(url);
- editPhotoBtn.textContent = "📷 Replace photo";
- editPhotoClear.hidden = false;
- } else {
- setEditPreviewFromURL(null);
- editPhotoBtn.textContent = "📷 Add photo";
- editPhotoClear.hidden = true;
+ resetEditPhotos();
+ for (const id of photoIdsOf(ev)) {
+ editPhotos.push({ id, url: await photoSrc(id) });
}
+ renderEditPhotos();
editDialog.showModal();
}
editPhotoBtn.addEventListener("click", () => editPhotoInput.click());
- editPhotoClear.addEventListener("click", () => {
- editPhotoCleared = true;
- clearEditPhotoLocalState();
- setEditPreviewFromURL(null);
- editPhotoBtn.textContent = "📷 Add photo";
- editPhotoClear.hidden = true;
- });
editPhotoInput.addEventListener("change", async (e) => {
- const file = e.target.files?.[0];
- if (!file) return;
- try {
- editPhotoBlob = await resizeImage(file);
- if (editPhotoURL) URL.revokeObjectURL(editPhotoURL);
- editPhotoURL = URL.createObjectURL(editPhotoBlob);
- setEditPreviewFromURL(editPhotoURL);
- editPhotoCleared = true; // a new photo supersedes any existing one
- editPhotoBtn.textContent = "📷 Replace photo";
- editPhotoClear.hidden = false;
- } catch (err) {
- alert("Couldn't process that photo: " + err.message);
+ for (const file of Array.from(e.target.files || [])) {
+ try {
+ const blob = await resizeImage(file);
+ editPhotos.push({ blob, url: URL.createObjectURL(blob) });
+ } catch (err) {
+ alert("Couldn't process that photo: " + err.message);
+ }
}
+ editPhotoInput.value = "";
+ renderEditPhotos();
});
editForm.querySelector('button[value="save"]').addEventListener("click", async (e) => {
@@ -1917,24 +1934,25 @@
patch.grams = Math.round(g);
}
}
- if (editPhotoBlob) {
- const newId = uuid();
- try { await putPhoto(newId, editPhotoBlob, false); }
+ const photoIds = [];
+ for (const p of editPhotos) {
+ if (p.id) { photoIds.push(p.id); continue; }
+ const id = uuid();
+ try { await putPhoto(id, p.blob, false); }
catch (err) { alert("Couldn't store photo locally: " + err.message); return; }
- patch.photoId = newId;
- } else if (editPhotoCleared) {
- patch.photoId = "";
+ photoIds.push(id);
}
+ patch.photoId = photoIds.join(",");
updateEvent(editingId, patch);
editingId = null;
- clearEditPhotoLocalState();
+ resetEditPhotos();
editDialog.close();
});
editForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => {
e.preventDefault();
editingId = null;
- clearEditPhotoLocalState();
+ resetEditPhotos();
editDialog.close();
});
@@ -1944,7 +1962,7 @@
deleteEvent(editingId);
}
editingId = null;
- clearEditPhotoLocalState();
+ resetEditPhotos();
editDialog.close();
});
diff --git a/src/changelog.json b/src/changelog.json
index 56a4c51..b5be88d 100644
--- a/src/changelog.json
+++ b/src/changelog.json
@@ -1,4 +1,5 @@
[
+ { "date": "2026-07-13", "text": "Attach multiple photos to an event — the photo picker now also offers the gallery with multi-select" },
{ "date": "2026-07-13", "text": "Track food by weight: logging a meal asks for grams (optional), with a daily total in the overview and a weekly chart" },
{ "date": "2026-07-12", "text": "Browse the full changelog from the bottom of the page" },
{ "date": "2026-07-12", "text": "Finer-grained y-axis on the daily counts chart" },
diff --git a/src/index.html b/src/index.html
index 393a33e..cc78b61 100644
--- a/src/index.html
+++ b/src/index.html
@@ -332,9 +332,8 @@