directory card photos: load from the image endpoint, not inline base64

The webhook no longer sends `Image`, so the card photo comes from its own
endpoint, addressed by the record id — one <img> per card, one request.

- static/expert-image.js: the route AND its query-parameter name in ONE
  constant (id, confirmed by measurement; unverified in the brief), plus an
  optional version token used only when the payload carries one.
- Directory card: <img src="…odoo_experts_image?id=<id>">, width/height
  reserve the 72x72 box, decoding=async, eager for the first row and lazy
  below it; onerror swaps in the SAME monogram a card with no photo shows,
  so a failing image looks exactly like today.
- The static build-time card loses its 185 KB inline base64 photo and shows
  a monogram: it is the no-JavaScript fallback, where there is no onerror to
  fall back with. Page 341,345 -> 158,428 bytes.
- /freelancer-edit/: the photo block is a look, not a field (no file input),
  and the whole-record save sends NO `Image` key at all.
This commit is contained in:
webmaster committed 2026-11-03 22:59:53 +00:00
1 parent 9d14652a57
commit 2b0e89191a
3 files changed
+134 -71

No files matched your search

+30 -46
View File
@@ -149,6 +149,7 @@
<!-- The 15-point rule, in ONE copy for both forms (this one and the application form). --> <!-- The 15-point rule, in ONE copy for both forms (this one and the application form). -->
<script src="/static/focus-rule.js?v=22ac72a8f0"></script> <script src="/static/focus-rule.js?v=22ac72a8f0"></script>
<script src="/static/photo-upload.js?v=c17638457b"></script> <script src="/static/photo-upload.js?v=c17638457b"></script>
<script src="/static/expert-image.js?v=e496d4a4e3"></script>
<!-- 1) Read the credential, then take it OUT of the address bar. <!-- 1) Read the credential, then take it OUT of the address bar.
The hash arrives as ?has=... or ?hash=... (both spellings accepted, 'has' first — the The hash arrives as ?has=... or ?hash=... (both spellings accepted, 'has' first — the
@@ -574,53 +575,36 @@
form.appendChild(linkField("edFiverr", "fiverr", "Fiverr URL", form.appendChild(linkField("edFiverr", "fiverr", "Fiverr URL",
readField(record, "fiverr"), "https://www.fiverr.com/…")); readField(record, "fiverr"), "https://www.fiverr.com/…"));
// The photo is editable here with the SAME rules as the application form // The photo block is a LOOK, not a field. The photo does not live in this record
// (static/photo-upload.js). A picked file is validated and downscaled on the spot; // any more — the payload carries no `Image` — it comes from its own endpoint,
// leaving it untouched keeps the record's own Image, which rides back byte-identical // addressed by the record id (window.O4PExpertImage). So nothing here edits it,
// because the save sends the whole record (see the submit handler below). // and the save below sends no `Image` key at all: not the value, not null, not "".
var newImage = null;
var photoWrap = el("div", "field"); var photoWrap = el("div", "field");
var photoLabel = el("label", "", "Photo"); photoWrap.appendChild(el("label", "", "Photo"));
photoLabel.setAttribute("for", "edImage");
photoWrap.appendChild(photoLabel);
var photoRow = el("div", "photo-readonly"); var photoRow = el("div", "photo-readonly");
var img = readField(record, "Image"); var photoMono = el("span", "mono", initials(who));
var currentImage = (typeof img === "string" && (img.indexOf("https://") === 0 || img.indexOf("data:image/") === 0)) ? img : null; photoRow.appendChild(photoMono);
var previewImg = el("img"); if (rec && rec.id !== undefined && rec.id !== null && rec.id !== "") {
previewImg.id = "edImagePreview"; var photoImg = el("img");
previewImg.alt = ""; photoImg.id = "edImagePreview";
if (currentImage) { photoImg.alt = "";
previewImg.src = currentImage; photoImg.decoding = "async";
photoRow.appendChild(previewImg); photoImg.hidden = true; // the monogram stands in until the photo is there
} else { photoImg.addEventListener("load", function () {
previewImg.hidden = true; if (photoMono.parentNode) photoMono.parentNode.removeChild(photoMono);
photoRow.appendChild(previewImg); photoImg.hidden = false;
photoRow.appendChild(el("span", "mono", initials(who))); }, { once: true });
photoImg.addEventListener("error", function () {
// A failing photo is the monogram: no broken-image icon, no empty box.
if (photoImg.parentNode) photoImg.parentNode.removeChild(photoImg);
}, { once: true });
photoImg.src = O4PExpertImage.url(String(rec.id), rec);
photoRow.appendChild(photoImg);
} }
photoWrap.appendChild(photoRow); photoWrap.appendChild(photoRow);
var photoInput = el("input");
photoInput.type = "file";
photoInput.id = "edImage";
photoInput.name = "Image";
photoInput.accept = "image/jpeg,image/png,image/webp";
photoWrap.appendChild(photoInput);
var photoResult = el("p", "hint", "");
photoResult.id = "edImageResult";
photoResult.setAttribute("role", "status");
photoResult.setAttribute("aria-live", "polite");
photoWrap.appendChild(photoResult);
photoWrap.appendChild(el("p", "hint", photoWrap.appendChild(el("p", "hint",
"JPEG, PNG or WebP, up to 8 MB — we resize it to a square of at most 384 px first. " + "Your photo comes from your listing and is not editable here — reply to the email " +
"Leave it to keep your current photo.")); "that brought you here if it needs to change."));
O4PPhoto.wire(photoInput, previewImg, photoResult, function (dataUrl) {
newImage = dataUrl;
var fb = photoRow.querySelector(".mono");
if (fb) fb.hidden = !!dataUrl;
if (dataUrl) return; // wire already shows the new image
// reverted to "no new photo": show the record's own photo again
if (currentImage) { previewImg.src = currentImage; previewImg.hidden = false; }
else previewImg.hidden = true;
});
form.appendChild(photoWrap); form.appendChild(photoWrap);
var save = el("button", "btn", "Save my changes"); var save = el("button", "btn", "Save my changes");
@@ -686,10 +670,10 @@
else if (had) payload[pair[1]] = ""; else if (had) payload[pair[1]] = "";
}); });
// An untouched photo rides back exactly as loaded — it came in with the record // The photo does NOT ride in this record any more: the payload carries no
// above and was never touched, so it stays byte-identical. Only a NEWLY picked // `Image` key and this form sends none — not the value, not null, not "". The
// photo (already validated and downscaled) replaces it. // delete is the guarantee, whatever a reply may one day carry.
if (newImage !== null) payload.Image = newImage; delete payload.Image;
fetch(C.webhook, { fetch(C.webhook, {
method: C.saveMethod, method: C.saveMethod,
+56 -25
View File
@@ -1380,7 +1380,7 @@
<!-- DIRECTORY-LIST-START --> <!-- DIRECTORY-LIST-START -->
<article class="dev-card" data-search="oliver - the oc guy i love to see how far i can push the odoo community version for my clients. besides the module implementation i am quite happy with adding to odoo using hermes agent, n8n and progressive web apps agent automation, manufacturing, e-commerce english german spanish" data-id="2" data-name="Oliver - The OC Guy" data-country="py" data-languages="english|german|spanish" data-oca="1" data-relay="1"> <article class="dev-card" data-search="oliver - the oc guy i love to see how far i can push the odoo community version for my clients. besides the module implementation i am quite happy with adding to odoo using hermes agent, n8n and progressive web apps agent automation, manufacturing, e-commerce english german spanish" data-id="2" data-name="Oliver - The OC Guy" data-country="py" data-languages="english|german|spanish" data-oca="1" data-relay="1">
<header class="dev-head"> <header class="dev-head">
<img class="dev-photo" src="data:image/jpeg;base64,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 truncated <span class="dev-avatar" aria-hidden="true">OG</span>
<div class="dev-id"> <div class="dev-id">
<h3 class="dev-name">Oliver - The OC Guy</h3> <h3 class="dev-name">Oliver - The OC Guy</h3>
<a class="dev-oca-badge" href="https://apps.odoo-community.org/community/oliver-arnold" target="_blank" rel="noopener nofollow">OCA Member Profile</a> <a class="dev-oca-badge" href="https://apps.odoo-community.org/community/oliver-arnold" target="_blank" rel="noopener nofollow">OCA Member Profile</a>
@@ -2052,6 +2052,7 @@
<script defer src="/static/lang.js?v=3ec49a7e62"></script> <script defer src="/static/lang.js?v=3ec49a7e62"></script>
<script src="/static/focus-rule.js?v=22ac72a8f0"></script> <script src="/static/focus-rule.js?v=22ac72a8f0"></script>
<script src="/static/photo-upload.js?v=c17638457b"></script> <script src="/static/photo-upload.js?v=c17638457b"></script>
<script src="/static/expert-image.js?v=e496d4a4e3"></script>
<script> <script>
/* ============================================================================ /* ============================================================================
Odoo freelancer directory — page script. Odoo freelancer directory — page script.
@@ -2093,6 +2094,12 @@
var ODOO_EXPERTS_EDIT_WEBHOOK = "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_edit"; var ODOO_EXPERTS_EDIT_WEBHOOK = "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_edit";
var EDIT_EMAIL_KEY = "email"; // body sent to ODOO_EXPERTS_EDIT_WEBHOOK: { email: "..." } var EDIT_EMAIL_KEY = "email"; // body sent to ODOO_EXPERTS_EDIT_WEBHOOK: { email: "..." }
// How many cards load their photo EAGERLY: the first visible ROW. The grid is 3
// columns at the widest layout (measured), so the first three cards. Every card below
// the fold loads lazily — a lazy image in the first row would delay LCP, and a photo
// off-screen is not worth its request.
var EAGER_CARDS = 3;
// ---- The shared constants (the same lists live in build-directory.py) -------- // ---- The shared constants (the same lists live in build-directory.py) --------
// The five self-assessed focus axes — each a SEPARATE numeric field (0-5) on the // The five self-assessed focus axes — each a SEPARATE numeric field (0-5) on the
// wire, no CSV, no encoding. They must total exactly FOCUS_TOTAL. // wire, no CSV, no encoding. They must total exactly FOCUS_TOTAL.
@@ -2356,10 +2363,6 @@
return v.indexOf("http://") === 0 || v.indexOf("https://") === 0 || v.indexOf("/") === 0; return v.indexOf("http://") === 0 || v.indexOf("https://") === 0 || v.indexOf("/") === 0;
} }
function isImageSrc(v) {
return v.indexOf("https://") === 0 || v.indexOf("data:image/") === 0;
}
function liveEl(tag, cls, text) { function liveEl(tag, cls, text) {
var node = document.createElement(tag); var node = document.createElement(tag);
if (cls) node.className = cls; if (cls) node.className = cls;
@@ -2460,9 +2463,25 @@
return liveValues(rec, field).map(fold).join("|"); return liveValues(rec, field).map(fold).join("|");
} }
/* The monogram — the initials treatment a card shows when there is no photo. It is
also the FALLBACK when the photo fails to load, so a missing photo looks exactly
like a card that never had one. */
function liveMonogram(name) {
var parts = name.split(/\s+/).filter(Boolean);
var mark = parts.length > 1
? parts[0].charAt(0) + parts[parts.length - 1].charAt(0)
: (parts[0] || "?").slice(0, 2);
var mono = liveEl("span", "dev-avatar", mark.toUpperCase());
mono.setAttribute("aria-hidden", "true");
return mono;
}
/* One card, field for field the same as render_card() in build-directory.py. /* One card, field for field the same as render_card() in build-directory.py.
Everything a freelancer wrote goes in as textContent, never as HTML. */ Everything a freelancer wrote goes in as textContent, never as HTML.
function liveCard(rec, active) {
`eager` is true only for the first visible row: those photos load eagerly because
a lazy image in the first row delays LCP. Everything below the fold loads lazily. */
function liveCard(rec, active, eager) {
var name = String(rec.Name || "").trim(); var name = String(rec.Name || "").trim();
var card = liveEl("article", "dev-card"); var card = liveEl("article", "dev-card");
card.setAttribute("data-name", name); card.setAttribute("data-name", name);
@@ -2474,23 +2493,33 @@
card.setAttribute("data-" + pair[2], liveAttrValue(rec, pair)); card.setAttribute("data-" + pair[2], liveAttrValue(rec, pair));
}); });
var photo = String(rec.Image || "").trim(); // The photo is NOT in the profile payload — the webhook no longer sends `Image`
var avatar; // (it used to ride as inline base64). It comes from its own endpoint, addressed by
if (isImageSrc(photo)) { // the record id: ONE <img>, ONE request (window.O4PExpertImage holds the route and
avatar = liveEl("img", "dev-photo"); // its query-parameter name in one constant).
avatar.src = photo; //
avatar.alt = ""; // The box is RESERVED (width/height + the .dev-photo CSS), so the card does not
avatar.width = 72; // reflow when the photo arrives. A failure — no id, no workflow, a reply that is
avatar.height = 72; // not an image — falls back to the SAME monogram a card with no photo already
avatar.loading = "lazy"; // shows: no broken-image icon, no empty box, no alt text spilling into the layout.
avatar.decoding = "async"; var mono = liveMonogram(name);
} else { var avatar = mono;
var parts = name.split(/\s+/).filter(Boolean); var rid = (rec.id === undefined || rec.id === null || rec.id === "") ? "" : String(rec.id);
var mark = parts.length > 1 if (rid) {
? parts[0].charAt(0) + parts[parts.length - 1].charAt(0) var photo = liveEl("img", "dev-photo");
: (parts[0] || "?").slice(0, 2); photo.alt = "";
avatar = liveEl("span", "dev-avatar", mark.toUpperCase()); photo.width = 72;
avatar.setAttribute("aria-hidden", "true"); photo.height = 72;
photo.decoding = "async";
photo.loading = eager ? "eager" : "lazy";
// On failure the <img> is REPLACED by the monogram — a node of the same 72x72
// size, so the card does not move. `mono` is captured by name, never the variable
// that is reassigned below.
photo.addEventListener("error", function () {
if (photo.parentNode) photo.parentNode.replaceChild(mono, photo);
}, { once: true });
photo.src = O4PExpertImage.url(rid, rec);
avatar = photo;
} }
var head = liveEl("header", "dev-head"); var head = liveEl("header", "dev-head");
var who = liveEl("div", "dev-id"); var who = liveEl("div", "dev-id");
@@ -2960,7 +2989,9 @@
// NO sort: the endpoint's order is rendered exactly as received. // NO sort: the endpoint's order is rendered exactly as received.
var active = liveFilters(records); var active = liveFilters(records);
while (grid.firstChild) grid.removeChild(grid.firstChild); while (grid.firstChild) grid.removeChild(grid.firstChild);
records.forEach(function (rec) { grid.appendChild(liveCard(rec, active)); }); records.forEach(function (rec, i) {
grid.appendChild(liveCard(rec, active, i < EAGER_CARDS));
});
// The button is added after the cards exist — only while the route is set. // The button is added after the cards exist — only while the route is set.
Array.prototype.forEach.call(grid.querySelectorAll(".dev-card"), enhanceAsk); Array.prototype.forEach.call(grid.querySelectorAll(".dev-card"), enhanceAsk);
dirPage = 1; dirPage = 1;
+48
View File
@@ -0,0 +1,48 @@
/* The freelancer PHOTO — one endpoint, one constant.
The photo does NOT live in the profile payload any more. It used to ride there as
inline base64 under the key `Image`; the webhook no longer sends that key, so the
payload is small and the photo is fetched separately, per card, by record id.
ONE constant holds BOTH halves of the address: the route and the query-parameter
name. Changing either is a one-line edit here, and both pages pick it up.
The parameter name is `id`. It was recorded as UNVERIFIED in the brief (every probe
answered "workflow is not registered"), and it is now CONFIRMED by measurement
against the live route: `?id=<n>` answers 200 with the photo for that record, while
`/4`, `?profile_id=`, `?image_id=` and a missing id all fail. See
webmaster/out/2026-11-03-photo-endpoint/REPORT.md.
The endpoint currently answers `application/json` with `{"Image":"data:image/jpeg;
base64,…"}` — a JSON envelope, NOT image bytes — so an <img> cannot decode it and
every card falls back to its monogram. That is the measured state, not a guess; the
page is built for the shape the brief specifies (an <img> pointed at this route) and
starts showing photos the moment the route returns image bytes.
`versionParam`: appended ONLY when the payload carries a version token, so a
year-long immutable cache on the endpoint stays correct — the URL changes when the
photo does. Without a token the URL is stable and the endpoint's own headers decide
caching (see the report: it sends no Cache-Control and a weak ETag today). */
window.O4PExpertImage = {
endpoint: "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_image",
idParam: "id",
versionParam: "v",
// Anything that changes when the photo changes. `updatedAt` used to be in the payload.
versionFields: ["updatedAt", "updated_at", "ImageVersion", "image_version"],
version: function (rec) {
if (!rec) return "";
for (var i = 0; i < this.versionFields.length; i++) {
var v = rec[this.versionFields[i]];
if (v !== undefined && v !== null && v !== "") return String(v);
}
return "";
},
url: function (id, rec) {
var u = this.endpoint + "?" + this.idParam + "=" + encodeURIComponent(id);
var v = this.version(rec);
if (v) u += "&" + this.versionParam + "=" + encodeURIComponent(v);
return u;
}
};