From a202f3e929294f38c293073fbd4a0d9203a07314 Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Mon, 13 Jul 2026 20:34:07 +0000 Subject: [PATCH] Support multiple photos per event photoId now holds one or more photo UUIDs, comma-separated. The server never interprets the field (photos are uploaded and served individually by UUID), so no schema change is needed and legacy single-photo events are already valid one-element lists. Both dialogs let you keep adding photos, previewed as thumbnails with a per-photo remove button; the file input allows multi-select and no longer forces the camera, so the gallery is available too. History rows show every photo, each opening in the lightbox. --- server/main.go | 2 +- src/app.js | 190 +++++++++++++++++++++++++-------------------- src/changelog.json | 1 + src/index.html | 10 +-- src/style.css | 26 ++++++- 5 files changed, 133 insertions(+), 96 deletions(-) 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 @@
- - - + +
@@ -363,9 +362,8 @@
- - - + +
diff --git a/src/style.css b/src/style.css index 56254c1..d0db350 100644 --- a/src/style.css +++ b/src/style.css @@ -432,13 +432,33 @@ dialog menu { .photo-preview { margin-top: 8px; width: 100%; + display: flex; + flex-wrap: wrap; + gap: 10px; } -.photo-preview img { +.photo-preview[hidden] { display: none; } +.photo-thumb { position: relative; } +.photo-thumb img { display: block; - max-width: 100%; - max-height: 240px; + width: 76px; + height: 76px; + object-fit: cover; border-radius: 8px; border: 1px solid var(--border); + background: var(--accent-soft); +} +.photo-thumb-remove { + position: absolute; + top: -7px; + right: -7px; + width: 22px; + height: 22px; + padding: 0; + border-radius: 50%; + background: var(--danger); + color: #fff; + font-size: 14px; + line-height: 1; } .event .thumb {