Public Access
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:
1 parent
2b0e89191a
commit
29ff175e89
3 files changed
+181
-76
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user