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.
This commit is contained in:
Alexander Heldt
2026-07-13 20:34:07 +00:00
parent 3a829161e3
commit a202f3e929
5 changed files with 133 additions and 96 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ type Event struct {
Type string `json:"type"` Type string `json:"type"`
At int64 `json:"at"` At int64 `json:"at"`
Note string `json:"note"` 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 Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
+98 -80
View File
@@ -779,18 +779,18 @@
} }
li.addEventListener("click", () => openEditDialog(ev)); li.addEventListener("click", () => openEditDialog(ev));
if (ev.photoId) { for (const pid of photoIdsOf(ev)) {
const img = document.createElement("img"); const img = document.createElement("img");
img.className = "thumb"; img.className = "thumb";
img.alt = "photo"; img.alt = "photo";
img.loading = "lazy"; img.loading = "lazy";
img.dataset.photoId = ev.photoId; img.dataset.photoId = pid;
img.addEventListener("click", (e) => { img.addEventListener("click", (e) => {
e.stopPropagation(); // don't open the edit dialog e.stopPropagation(); // don't open the edit dialog
openLightbox(ev.photoId); openLightbox(pid);
}); });
li.appendChild(img); 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); eventList.appendChild(li);
@@ -1695,27 +1695,60 @@
const noteTitle = document.getElementById("note-title"); const noteTitle = document.getElementById("note-title");
const notePhotoInput = document.getElementById("note-photo-input"); const notePhotoInput = document.getElementById("note-photo-input");
const notePhotoBtn = document.getElementById("note-photo-btn"); const notePhotoBtn = document.getElementById("note-photo-btn");
const notePhotoClear = document.getElementById("note-photo-clear");
const notePhotoPreview = document.getElementById("note-photo-preview"); const notePhotoPreview = document.getElementById("note-photo-preview");
const noteWeightField = document.getElementById("note-weight-field"); const noteWeightField = document.getElementById("note-weight-field");
const noteWeight = document.getElementById("note-weight"); const noteWeight = document.getElementById("note-weight");
const noteGramsField = document.getElementById("note-grams-field"); const noteGramsField = document.getElementById("note-grams-field");
const noteGrams = document.getElementById("note-grams"); const noteGrams = document.getElementById("note-grams");
let pendingType = null; let pendingType = null;
let notePhotoBlob = null; // pending blob for the dialog (not yet committed) let notePhotos = []; // pending photos for this dialog: [{ blob, url }]
let notePhotoURL = null; // current preview object URL
// Whether the user has manually touched the date/time fields. While false the // 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 // 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. // input, so a just-logged event doesn't look up to ~59s old.
let noteTimeEdited = false; let noteTimeEdited = false;
function clearNotePhoto() { // An event can carry several photos: photoId holds their UUIDs
notePhotoBlob = null; // comma-separated. A legacy single id is just a one-element list, and the
if (notePhotoURL) { URL.revokeObjectURL(notePhotoURL); notePhotoURL = null; } // server passes the string through untouched (photos themselves are
notePhotoPreview.hidden = true; // 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 = ""; notePhotoPreview.innerHTML = "";
notePhotoClear.hidden = true; notePhotoPreview.hidden = notePhotos.length === 0;
notePhotoBtn.textContent = "📷 Add photo"; 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 = ""; notePhotoInput.value = "";
} }
@@ -1733,7 +1766,7 @@
noteWeight.value = ""; noteWeight.value = "";
noteGramsField.hidden = !isEat; noteGramsField.hidden = !isEat;
noteGrams.value = ""; noteGrams.value = "";
clearNotePhoto(); clearNotePhotos();
noteDialog.showModal(); noteDialog.showModal();
setTimeout(() => (isWeight ? noteWeight : isEat ? noteGrams : noteInput).focus(), 50); setTimeout(() => (isWeight ? noteWeight : isEat ? noteGrams : noteInput).focus(), 50);
} }
@@ -1760,21 +1793,17 @@
}); });
notePhotoBtn.addEventListener("click", () => notePhotoInput.click()); notePhotoBtn.addEventListener("click", () => notePhotoInput.click());
notePhotoClear.addEventListener("click", () => clearNotePhoto());
notePhotoInput.addEventListener("change", async (e) => { notePhotoInput.addEventListener("change", async (e) => {
const file = e.target.files?.[0]; for (const file of Array.from(e.target.files || [])) {
if (!file) return;
try { try {
notePhotoBlob = await resizeImage(file); const blob = await resizeImage(file);
if (notePhotoURL) URL.revokeObjectURL(notePhotoURL); notePhotos.push({ blob, url: URL.createObjectURL(blob) });
notePhotoURL = URL.createObjectURL(notePhotoBlob);
notePhotoPreview.innerHTML = `<img alt="" src="${notePhotoURL}">`;
notePhotoPreview.hidden = false;
notePhotoClear.hidden = false;
notePhotoBtn.textContent = "📷 Replace photo";
} catch (err) { } catch (err) {
alert("Couldn't process that photo: " + err.message); alert("Couldn't process that photo: " + err.message);
} }
}
notePhotoInput.value = "";
renderNotePhotos();
}); });
document.getElementById("note-save").addEventListener("click", async (e) => { 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; } if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; }
grams = Math.round(g); grams = Math.round(g);
} }
let photoId = ""; const photoIds = [];
if (notePhotoBlob) { for (const p of notePhotos) {
photoId = uuid(); const id = uuid();
try { await putPhoto(photoId, notePhotoBlob, false); } try { await putPhoto(id, p.blob, false); }
catch (err) { catch (err) {
alert("Couldn't store photo locally: " + err.message); alert("Couldn't store photo locally: " + err.message);
return; 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; pendingType = null;
clearNotePhoto(); clearNotePhotos();
noteDialog.close(); noteDialog.close();
}); });
noteForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { noteForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
pendingType = null; pendingType = null;
clearNotePhoto(); clearNotePhotos();
noteDialog.close(); noteDialog.close();
}); });
@@ -1822,27 +1852,32 @@
const editDelete = document.getElementById("edit-delete"); const editDelete = document.getElementById("edit-delete");
const editPhotoInput = document.getElementById("edit-photo-input"); const editPhotoInput = document.getElementById("edit-photo-input");
const editPhotoBtn = document.getElementById("edit-photo-btn"); const editPhotoBtn = document.getElementById("edit-photo-btn");
const editPhotoClear = document.getElementById("edit-photo-clear");
const editPhotoPreview = document.getElementById("edit-photo-preview"); const editPhotoPreview = document.getElementById("edit-photo-preview");
const editWeightField = document.getElementById("edit-weight-field"); const editWeightField = document.getElementById("edit-weight-field");
const editWeight = document.getElementById("edit-weight"); const editWeight = document.getElementById("edit-weight");
const editGramsField = document.getElementById("edit-grams-field"); const editGramsField = document.getElementById("edit-grams-field");
const editGrams = document.getElementById("edit-grams"); const editGrams = document.getElementById("edit-grams");
let editingId = null; let editingId = null;
let editPhotoId = ""; // current photoId for this event // The dialog's working set of photos, in display order. Existing photos are
let editPhotoBlob = null; // new blob chosen in this session // { id, url } (url from photoSrc's page-lifetime cache — never revoked here);
let editPhotoURL = null; // newly picked ones are { blob, url } with a fresh object URL we own.
let editPhotoCleared = false; // user removed an existing photo let editPhotos = [];
function setEditPreviewFromURL(url) { function renderEditPhotos() {
if (!url) { editPhotoPreview.hidden = true; editPhotoPreview.innerHTML = ""; return; } editPhotoPreview.innerHTML = "";
editPhotoPreview.innerHTML = `<img alt="" src="${url}">`; editPhotoPreview.hidden = editPhotos.length === 0;
editPhotoPreview.hidden = false; editPhotos.forEach((p, i) => {
editPhotoPreview.appendChild(photoThumb(p.url, () => {
if (p.blob) URL.revokeObjectURL(p.url);
editPhotos.splice(i, 1);
renderEditPhotos();
}));
});
} }
function clearEditPhotoLocalState() { function resetEditPhotos() {
editPhotoBlob = null; for (const p of editPhotos) if (p.blob) URL.revokeObjectURL(p.url);
if (editPhotoURL) { URL.revokeObjectURL(editPhotoURL); editPhotoURL = null; } editPhotos = [];
editPhotoInput.value = ""; editPhotoInput.value = "";
} }
@@ -1855,44 +1890,26 @@
editWeight.value = (ev.type === "weight" && Number.isFinite(ev.weight)) ? ev.weight : ""; editWeight.value = (ev.type === "weight" && Number.isFinite(ev.weight)) ? ev.weight : "";
editGramsField.hidden = ev.type !== "eat"; editGramsField.hidden = ev.type !== "eat";
editGrams.value = (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) ? ev.grams : ""; editGrams.value = (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) ? ev.grams : "";
editPhotoId = ev.photoId || ""; resetEditPhotos();
editPhotoCleared = false; for (const id of photoIdsOf(ev)) {
clearEditPhotoLocalState(); editPhotos.push({ id, url: await photoSrc(id) });
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;
} }
renderEditPhotos();
editDialog.showModal(); editDialog.showModal();
} }
editPhotoBtn.addEventListener("click", () => editPhotoInput.click()); 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) => { editPhotoInput.addEventListener("change", async (e) => {
const file = e.target.files?.[0]; for (const file of Array.from(e.target.files || [])) {
if (!file) return;
try { try {
editPhotoBlob = await resizeImage(file); const blob = await resizeImage(file);
if (editPhotoURL) URL.revokeObjectURL(editPhotoURL); editPhotos.push({ blob, url: URL.createObjectURL(blob) });
editPhotoURL = URL.createObjectURL(editPhotoBlob);
setEditPreviewFromURL(editPhotoURL);
editPhotoCleared = true; // a new photo supersedes any existing one
editPhotoBtn.textContent = "📷 Replace photo";
editPhotoClear.hidden = false;
} catch (err) { } catch (err) {
alert("Couldn't process that photo: " + err.message); alert("Couldn't process that photo: " + err.message);
} }
}
editPhotoInput.value = "";
renderEditPhotos();
}); });
editForm.querySelector('button[value="save"]').addEventListener("click", async (e) => { editForm.querySelector('button[value="save"]').addEventListener("click", async (e) => {
@@ -1917,24 +1934,25 @@
patch.grams = Math.round(g); patch.grams = Math.round(g);
} }
} }
if (editPhotoBlob) { const photoIds = [];
const newId = uuid(); for (const p of editPhotos) {
try { await putPhoto(newId, editPhotoBlob, false); } 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; } catch (err) { alert("Couldn't store photo locally: " + err.message); return; }
patch.photoId = newId; photoIds.push(id);
} else if (editPhotoCleared) {
patch.photoId = "";
} }
patch.photoId = photoIds.join(",");
updateEvent(editingId, patch); updateEvent(editingId, patch);
editingId = null; editingId = null;
clearEditPhotoLocalState(); resetEditPhotos();
editDialog.close(); editDialog.close();
}); });
editForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { editForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
editingId = null; editingId = null;
clearEditPhotoLocalState(); resetEditPhotos();
editDialog.close(); editDialog.close();
}); });
@@ -1944,7 +1962,7 @@
deleteEvent(editingId); deleteEvent(editingId);
} }
editingId = null; editingId = null;
clearEditPhotoLocalState(); resetEditPhotos();
editDialog.close(); editDialog.close();
}); });
+1
View File
@@ -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-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": "Browse the full changelog from the bottom of the page" },
{ "date": "2026-07-12", "text": "Finer-grained y-axis on the daily counts chart" }, { "date": "2026-07-12", "text": "Finer-grained y-axis on the daily counts chart" },
+4 -6
View File
@@ -332,9 +332,8 @@
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea> <textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
</label> </label>
<div class="photo-field"> <div class="photo-field">
<input type="file" id="note-photo-input" accept="image/*" capture="environment" hidden /> <input type="file" id="note-photo-input" accept="image/*" multiple hidden />
<button type="button" id="note-photo-btn" class="ghost">📷 Add photo</button> <button type="button" id="note-photo-btn" class="ghost">📷 Add photos</button>
<button type="button" id="note-photo-clear" class="ghost" hidden>Remove photo</button>
<div id="note-photo-preview" class="photo-preview" hidden></div> <div id="note-photo-preview" class="photo-preview" hidden></div>
</div> </div>
<menu> <menu>
@@ -363,9 +362,8 @@
<textarea id="edit-note" rows="4"></textarea> <textarea id="edit-note" rows="4"></textarea>
</label> </label>
<div class="photo-field"> <div class="photo-field">
<input type="file" id="edit-photo-input" accept="image/*" capture="environment" hidden /> <input type="file" id="edit-photo-input" accept="image/*" multiple hidden />
<button type="button" id="edit-photo-btn" class="ghost">📷 Add photo</button> <button type="button" id="edit-photo-btn" class="ghost">📷 Add photos</button>
<button type="button" id="edit-photo-clear" class="ghost" hidden>Remove photo</button>
<div id="edit-photo-preview" class="photo-preview" hidden></div> <div id="edit-photo-preview" class="photo-preview" hidden></div>
</div> </div>
<menu> <menu>
+23 -3
View File
@@ -432,13 +432,33 @@ dialog menu {
.photo-preview { .photo-preview {
margin-top: 8px; margin-top: 8px;
width: 100%; 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; display: block;
max-width: 100%; width: 76px;
max-height: 240px; height: 76px;
object-fit: cover;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--border); 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 { .event .thumb {