Public Access
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:
1 parent
9d14652a57
commit
2b0e89191a
3 files changed
+134
-71
No files matched your search
+30
-46
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in new issue
Block a user