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:
+104
-86
@@ -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 = `<img alt="" src="${notePhotoURL}">`;
|
||||
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 = `<img alt="" src="${url}">`;
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
+4
-6
@@ -332,9 +332,8 @@
|
||||
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
|
||||
</label>
|
||||
<div class="photo-field">
|
||||
<input type="file" id="note-photo-input" accept="image/*" capture="environment" hidden />
|
||||
<button type="button" id="note-photo-btn" class="ghost">📷 Add photo</button>
|
||||
<button type="button" id="note-photo-clear" class="ghost" hidden>Remove photo</button>
|
||||
<input type="file" id="note-photo-input" accept="image/*" multiple hidden />
|
||||
<button type="button" id="note-photo-btn" class="ghost">📷 Add photos</button>
|
||||
<div id="note-photo-preview" class="photo-preview" hidden></div>
|
||||
</div>
|
||||
<menu>
|
||||
@@ -363,9 +362,8 @@
|
||||
<textarea id="edit-note" rows="4"></textarea>
|
||||
</label>
|
||||
<div class="photo-field">
|
||||
<input type="file" id="edit-photo-input" accept="image/*" capture="environment" hidden />
|
||||
<button type="button" id="edit-photo-btn" class="ghost">📷 Add photo</button>
|
||||
<button type="button" id="edit-photo-clear" class="ghost" hidden>Remove photo</button>
|
||||
<input type="file" id="edit-photo-input" accept="image/*" multiple hidden />
|
||||
<button type="button" id="edit-photo-btn" class="ghost">📷 Add photos</button>
|
||||
<div id="edit-photo-preview" class="photo-preview" hidden></div>
|
||||
</div>
|
||||
<menu>
|
||||
|
||||
+23
-3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user