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
+191 -86

No files matched your search

+80 -40
View File
@@ -2052,7 +2052,7 @@
<script defer src="/static/lang.js?v=3ec49a7e62"></script>
<script src="/static/focus-rule.js?v=22ac72a8f0"></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>
/* ============================================================================
Odoo freelancer directory — page script.
@@ -2094,12 +2094,6 @@
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: "..." }
// 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 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.
@@ -2463,8 +2457,76 @@
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
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. */
function liveMonogram(name) {
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.
Everything a freelancer wrote goes in as textContent, never as HTML.
`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) {
Everything a freelancer wrote goes in as textContent, never as HTML. */
function liveCard(rec, active) {
var name = String(rec.Name || "").trim();
var card = liveEl("article", "dev-card");
card.setAttribute("data-name", name);
@@ -2495,32 +2554,11 @@
// 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
// the record id: ONE <img>, ONE request (window.O4PExpertImage holds the route and
// its query-parameter name in one constant).
//
// 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.
// the record id and fetched lazily (mountCardPhoto). The monogram RESERVES the box
// 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.
var mono = liveMonogram(name);
var avatar = mono;
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 who = liveEl("div", "dev-id");
who.appendChild(liveEl("h3", "dev-name", name));
@@ -2533,9 +2571,11 @@
badge.rel = "noopener nofollow";
who.appendChild(badge);
}
head.appendChild(avatar);
head.appendChild(mono);
head.appendChild(who);
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.
var clients = parseInt(rec.odoo4projects_clients, 10);
@@ -2989,8 +3029,8 @@
// NO sort: the endpoint's order is rendered exactly as received.
var active = liveFilters(records);
while (grid.firstChild) grid.removeChild(grid.firstChild);
records.forEach(function (rec, i) {
grid.appendChild(liveCard(rec, active, i < EAGER_CARDS));
records.forEach(function (rec) {
grid.appendChild(liveCard(rec, active));
});
// The button is added after the cards exist — only while the route is set.
Array.prototype.forEach.call(grid.querySelectorAll(".dev-card"), enhanceAsk);