Files
ODOO4projects.com/static/expert-image.js
T
webmaster 29ff175e89 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.
2026-11-03 23:19:06 +00:00

111 lines
5.2 KiB
JavaScript

/* 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
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`, 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.
THE ENDPOINT RETURNS JSON, NOT IMAGE BYTES. Measured on the live route:
GET …/odoo_experts_image?id=10 → 200 application/json
body: {"Image":"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ…"}
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 = {
endpoint: "https://n8n.odoo4projects.com/webhook/ODOO4projects/odoo_experts_image",
idParam: "id",
// A photo request that hangs is a card with no photo AND no fallback: bound it.
timeoutMs: 8000,
/* The ONLY allowlist for an image source in this codebase — `https://` or a
`data:image/` URI. Both forms are what this endpoint has served across its life. */
isImageSrc: function (v) {
return typeof v === "string" &&
(v.indexOf("https://") === 0 || v.indexOf("data:image/") === 0);
},
url: function (id) {
return this.endpoint + "?" + this.idParam + "=" + encodeURIComponent(id);
},
// 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;
}
};