directory card photo: fetch the JSON envelope, decode the data URI, and bound every request

The endpoint answers 200 application/json with {"Image":"data:image/jpeg;base64,…"} —
a JSON envelope, NOT image bytes. An <img src="…?id=N"> pointed at it never settles:
measured on the live page, complete=false · naturalWidth=0 for 20 s, so the fallback
never fired and the card showed an empty slot. The card now FETCHES the route, reads
`Image` and puts the data URI on an <img> only when it passes the isImageSrc allowlist.

- static/expert-image.js: `load(id)` replaces `url(id, rec)`. It fetches the route,
  reads `Image`, accepts the array shape n8n sometimes returns, and resolves to a usable
  image source or `null` on ANY failure. EVERY request is bounded by `timeoutMs` (8000):
  an AbortController is aborted by a setTimeout, the abort rejects the fetch and lands in
  the same catch that returns null — so a hang becomes the monogram, not an empty box.
  The timer is cleared on every completion. The decoded URI is cached by id (failures
  too), so a re-render or a shared id never re-fetches. Every non-success path — timeout,
  abort, non-2xx, {}, a blank/missing Image, a value that fails isImageSrc — ends in the
  monogram: never an empty box, never a broken-image icon, never alt text in the layout.
- Directory card (odoo-community-developers/index.html): the monogram reserves the 72x72
  box and stands until a real photo has DECODED; the photo is requested lazily, only when
  a card nears the viewport (IntersectionObserver, rootMargin 200px), so a 14-card page
  does not fire 1.4 MB on load. The monogram and the photo are the same 72x72 box, so the
  swap moves nothing (measured: head box 274.81x72 and ask-row tops byte-identical across
  a loaded photo and a monogram).
- /freelancer-edit/: the photo preview makes the same fetch, so the form no longer points
  an <img> at the JSON route either.

Proved in a real browser against the repo served with the two webhook URLs stubbed:
jpeg data URI -> photo (src prefix data:image/jpeg;base64,/, naturalWidth 800); {} / 404 /
500 / a bad value -> monogram, 0 broken-image icons, 0 cards with neither; a stub that
never answers -> monogram after 8000 ms with the request ABORTED (client abort event at
8000.x ms, all 6 sockets disconnected server-side). No record created, no probe write.
This commit is contained in:
webmaster committed 2026-11-03 23:19:06 +00:00
1 parent 2b0e89191a
commit 29ff175e89
3 files changed
+181 -76

No files matched your search

+9 -6
View File
@@ -149,7 +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> <script src="/static/expert-image.js?v=bad5d8c733"></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
@@ -585,21 +585,24 @@
var photoMono = el("span", "mono", initials(who)); var photoMono = el("span", "mono", initials(who));
photoRow.appendChild(photoMono); photoRow.appendChild(photoMono);
if (rec && rec.id !== undefined && rec.id !== null && rec.id !== "") { if (rec && rec.id !== undefined && rec.id !== null && rec.id !== "") {
// Same fetch the directory card makes: the route answers JSON, not image bytes,
// so the value is read from `Image` and only then put on an <img>. Any failure
// (or a value that fails isImageSrc) leaves the monogram standing.
O4PExpertImage.load(String(rec.id)).then(function (src) {
if (!src) return;
var photoImg = el("img"); var photoImg = el("img");
photoImg.id = "edImagePreview"; photoImg.id = "edImagePreview";
photoImg.alt = ""; photoImg.alt = "";
photoImg.decoding = "async"; photoImg.decoding = "async";
photoImg.hidden = true; // the monogram stands in until the photo is there
photoImg.addEventListener("load", function () { photoImg.addEventListener("load", function () {
if (photoMono.parentNode) photoMono.parentNode.removeChild(photoMono); if (photoMono.parentNode) photoMono.parentNode.replaceChild(photoImg, photoMono);
photoImg.hidden = false;
}, { once: true }); }, { once: true });
photoImg.addEventListener("error", function () { photoImg.addEventListener("error", function () {
// A failing photo is the monogram: no broken-image icon, no empty box. // A failing photo is the monogram: no broken-image icon, no empty box.
if (photoImg.parentNode) photoImg.parentNode.removeChild(photoImg); if (photoImg.parentNode) photoImg.parentNode.removeChild(photoImg);
}, { once: true }); }, { once: true });
photoImg.src = O4PExpertImage.url(String(rec.id), rec); photoImg.src = src;
photoRow.appendChild(photoImg); });
} }
photoWrap.appendChild(photoRow); photoWrap.appendChild(photoRow);
photoWrap.appendChild(el("p", "hint", photoWrap.appendChild(el("p", "hint",
+80 -40
View File
@@ -2052,7 +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 src="/static/expert-image.js?v=bad5d8c733"></script>
<script> <script>
/* ============================================================================ /* ============================================================================
Odoo freelancer directory — page script. Odoo freelancer directory — page script.
@@ -2094,12 +2094,6 @@
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.
@@ -2463,8 +2457,76 @@
return liveValues(rec, field).map(fold).join("|"); return liveValues(rec, field).map(fold).join("|");
} }
/* ---- The card photo: fetched, cached, lazy, and monogram-on-failure ----------
The endpoint returns JSON, not image bytes (see static/expert-image.js), so a plain
<img src="…?id=N"> would receive JSON, fail to decode it and fire `onerror` — the
monogram. The card therefore FETCHES the route, reads `Image` and puts the data URI
on an <img> only when it passes the isImageSrc allowlist.
(1) LAZY, by viewport. There is no image URL for the browser to defer with
`loading="lazy"`, so the loading is ours: an IntersectionObserver watches each
card's head, and only cards near the viewport ask for their photo. The first
row is in view on load, so it loads at once; the rest load as they approach.
Without this a 14-card page fires 14 × ~100 KB ≈ 1.4 MB on load.
(2) CACHED by id (window.O4PExpertImage.load), so a re-render — or a card shown
again after paging — never re-fetches a photo already in memory.
(3) The MONOGRAM reserves the box and is the only thing an eye ever sees until a
real photo is ready: on any failure (non-2xx, {}, a blank value, a value that
fails isImageSrc) the fetch resolves null and the monogram simply stays. There
is never a broken-image icon and the box never changes size. */
var PHOTO_ROOT_MARGIN = "200px"; // one row ahead, roughly — not the whole page
var photoIO = null;
function getPhotoObserver() {
if (photoIO) return photoIO;
if (!("IntersectionObserver" in window)) return null;
photoIO = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
photoIO.unobserve(entry.target);
mountCardPhoto(entry.target, entry.target.__photoMono, entry.target.__photoId);
});
}, { rootMargin: PHOTO_ROOT_MARGIN });
return photoIO;
}
// Fetch once, then swap the monogram for the decoded <img>. The two nodes are the SAME
// 72x72 box (width/height + .dev-photo / .dev-avatar), so the swap moves nothing.
function mountCardPhoto(slot, mono, rid) {
O4PExpertImage.load(rid).then(function (src) {
if (!src) return; // any failure: the monogram stays
var photo = liveEl("img", "dev-photo");
photo.alt = "";
photo.width = 72;
photo.height = 72;
photo.decoding = "async";
// Swap only once the image has decoded — a detached <img> fires `load` for a data
// URI, so the monogram is on screen every frame until a real photo exists. A value
// that passed the prefix allowlist but still fails to decode falls back to it too.
photo.addEventListener("load", function () {
if (mono.parentNode) mono.parentNode.replaceChild(photo, mono);
}, { once: true });
photo.addEventListener("error", function () {
if (photo.parentNode) photo.parentNode.replaceChild(mono, photo);
}, { once: true });
photo.src = src;
});
}
// Watch this card's head: it loads its photo when it comes near the viewport. With no
// IntersectionObserver (vanishingly rare) the photo is fetched at once rather than
// never shown.
function registerCardPhoto(head, mono, rid) {
head.__photoMono = mono;
head.__photoId = rid;
var io = getPhotoObserver();
if (!io) { mountCardPhoto(head, mono, rid); return; }
io.observe(head);
}
/* The monogram — the initials treatment a card shows when there is no photo. It is /* 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 also the FALLBACK when the photo cannot be fetched, so a missing photo looks exactly
like a card that never had one. */ like a card that never had one. */
function liveMonogram(name) { function liveMonogram(name) {
var parts = name.split(/\s+/).filter(Boolean); var parts = name.split(/\s+/).filter(Boolean);
@@ -2477,11 +2539,8 @@
} }
/* 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);
@@ -2495,32 +2554,11 @@
// The photo is NOT in the profile payload — the webhook no longer sends `Image` // The photo is NOT in the profile payload — the webhook no longer sends `Image`
// (it used to ride as inline base64). It comes from its own endpoint, addressed by // (it used to ride as inline base64). It comes from its own endpoint, addressed by
// the record id: ONE <img>, ONE request (window.O4PExpertImage holds the route and // the record id and fetched lazily (mountCardPhoto). The monogram RESERVES the box
// its query-parameter name in one constant). // and stands until a real photo is decoded; on any failure it simply stays. No
// // broken-image icon, no empty box, no alt text spilling into the layout, no reflow.
// The box is RESERVED (width/height + the .dev-photo CSS), so the card does not
// reflow when the photo arrives. A failure — no id, no workflow, a reply that is
// not an image — falls back to the SAME monogram a card with no photo already
// shows: no broken-image icon, no empty box, no alt text spilling into the layout.
var mono = liveMonogram(name); var mono = liveMonogram(name);
var avatar = mono;
var rid = (rec.id === undefined || rec.id === null || rec.id === "") ? "" : String(rec.id); var rid = (rec.id === undefined || rec.id === null || rec.id === "") ? "" : String(rec.id);
if (rid) {
var photo = liveEl("img", "dev-photo");
photo.alt = "";
photo.width = 72;
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");
who.appendChild(liveEl("h3", "dev-name", name)); who.appendChild(liveEl("h3", "dev-name", name));
@@ -2533,9 +2571,11 @@
badge.rel = "noopener nofollow"; badge.rel = "noopener nofollow";
who.appendChild(badge); who.appendChild(badge);
} }
head.appendChild(avatar); head.appendChild(mono);
head.appendChild(who); head.appendChild(who);
card.appendChild(head); card.appendChild(head);
// The photo is requested only when this card nears the viewport (and never twice).
if (rid) registerCardPhoto(head, mono, rid);
// Our own count — rendered ONCE, with weight, never a score. // Our own count — rendered ONCE, with weight, never a score.
var clients = parseInt(rec.odoo4projects_clients, 10); var clients = parseInt(rec.odoo4projects_clients, 10);
@@ -2989,8 +3029,8 @@
// 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, i) { records.forEach(function (rec) {
grid.appendChild(liveCard(rec, active, i < EAGER_CARDS)); grid.appendChild(liveCard(rec, active));
}); });
// 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);
+92 -30
View File
@@ -1,4 +1,4 @@
/* The freelancer PHOTO — one endpoint, one constant. /* The freelancer PHOTO — one endpoint, one constant, one fetch.
The photo does NOT live in the profile payload any more. It used to ride there as 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 inline base64 under the key `Image`; the webhook no longer sends that key, so the
@@ -7,42 +7,104 @@
ONE constant holds BOTH halves of the address: the route and the query-parameter 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. 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 The parameter name is `id`, CONFIRMED by measurement against the live route:
answered "workflow is not registered"), and it is now CONFIRMED by measurement `?id=<n>` answers 200 with the photo for that record, while `/4`, `?profile_id=`,
against the live route: `?id=<n>` answers 200 with the photo for that record, while `?image_id=` and a missing id all fail.
`/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; THE ENDPOINT RETURNS JSON, NOT IMAGE BYTES. Measured on the live route:
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 GET …/odoo_experts_image?id=10 → 200 application/json
year-long immutable cache on the endpoint stays correct — the URL changes when the body: {"Image":"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ…"}
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). */ So a plain `<img src="…?id=N">` receives JSON, cannot decode it and fires `onerror`
— the monogram. That is why the card FETCHES the route, reads `Image` and puts the
DATA URI on an `<img>` only when it passes the `isImageSrc` allowlist. n8n sometimes
answers an array instead of an object, so both shapes are accepted.
`load(id)` resolves to a usable image source, or `null` on ANY failure — a non-2xx, an
empty `{}`, a missing/blank `Image`, a value that fails `isImageSrc`, or a request that
does not answer in `timeoutMs`. A `null` answer means "show the monogram": the caller
never sees a broken image. The decoded URI is cached by id (and a failure is remembered
too), so a re-render can never re-fetch a photo already in memory.
THE TIMEOUT. A request that neither succeeds nor errors leaves the fallback unable to
fire (measured live: an <img> pointed at this route sat `complete=false · naturalWidth=0`
for 20 s). Each fetch is therefore bounded by `timeoutMs` (default 8000): an
`AbortController` is aborted by a `setTimeout`, the abort rejects the fetch, and the
rejection lands in the same catch that returns `null` — the monogram. The timer is
cleared on every completion, so a fast answer never pays for the guard. `AbortController`
is universal in the browsers this site targets (it already needs `fetch`, `Promise` and
`IntersectionObserver`); if it were missing there is nothing to abort with, but the
abort is what turns a hang into the fallback, so it is the one capability worth naming.
The route answers with `Access-Control-Allow-Origin` echoing the caller and
`Cache-Control: public, max-age=3600`, so the browser may also serve a repeat from its
own HTTP cache. */
window.O4PExpertImage = { window.O4PExpertImage = {
endpoint: "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_image", endpoint: "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_image",
idParam: "id", idParam: "id",
versionParam: "v", // A photo request that hangs is a card with no photo AND no fallback: bound it.
// Anything that changes when the photo changes. `updatedAt` used to be in the payload. timeoutMs: 8000,
versionFields: ["updatedAt", "updated_at", "ImageVersion", "image_version"],
version: function (rec) { /* The ONLY allowlist for an image source in this codebase — `https://` or a
if (!rec) return ""; `data:image/` URI. Both forms are what this endpoint has served across its life. */
for (var i = 0; i < this.versionFields.length; i++) { isImageSrc: function (v) {
var v = rec[this.versionFields[i]]; return typeof v === "string" &&
if (v !== undefined && v !== null && v !== "") return String(v); (v.indexOf("https://") === 0 || v.indexOf("data:image/") === 0);
}
return "";
}, },
url: function (id, rec) { url: function (id) {
var u = this.endpoint + "?" + this.idParam + "=" + encodeURIComponent(id); return this.endpoint + "?" + this.idParam + "=" + encodeURIComponent(id);
var v = this.version(rec); },
if (v) u += "&" + this.versionParam + "=" + encodeURIComponent(v);
return u; // id -> decoded image source (or "" when the record has no usable photo)
cache: Object.create(null),
// id -> the in-flight Promise, so two observers of one id share ONE request
inflight: Object.create(null),
/* Fetch the photo for `id`. Resolves to an image source, or null when there is none.
Never rejects — a caller only has to check for a value. Every request is bounded by
`timeoutMs`: on expiry the fetch is ABORTED (not merely abandoned) and the rejection
resolves null, so a hung endpoint becomes the monogram instead of an empty box. */
load: function (id) {
var self = this;
var key = String(id);
if (key in self.cache) return Promise.resolve(self.cache[key] || null);
if (self.inflight[key]) return self.inflight[key];
var ctrl = (typeof AbortController !== "undefined") ? new AbortController() : null;
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, self.timeoutMs) : 0;
var req = fetch(self.url(key), {
headers: { Accept: "application/json" },
signal: ctrl ? ctrl.signal : undefined
})
.then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status);
return res.json();
})
.then(function (body) {
// n8n sometimes wraps the record in an array — accept both shapes.
var d = Array.isArray(body) ? body[0] : body;
var img = d && d.Image;
if (!self.isImageSrc(img)) throw new Error("no image");
self.cache[key] = img;
return img;
})
.catch(function () {
// Every failure — non-2xx, no image, a decode-hostile value, or the timeout's
// abort — is the monogram. Remember it, so a shared id under a re-render does not
// ask the network twice.
self.cache[key] = "";
return null;
})
.then(function (out) {
if (timer) clearTimeout(timer);
delete self.inflight[key];
return out;
});
self.inflight[key] = req;
return req;
} }
}; };