plresearch-org · edit-ai-opportunity-spaces-r4 · round 4
Closes r3's I2 item: admins can now edit the hero image field from the same UI that edits every other field on org.plresearch.opportunitySpace, with a live preview thumbnail. 47-line diff, no backend change, no schema change.
Headline. The image URL is now editable. The field already existed on the record and the PUT route was already round-tripping it untouched, so this round is purely UI: one EditSection with a URL textbox plus a small <ImagePreview> helper. Preview renders the image inline as the URL is typed, and fades to 25% opacity on broken URLs so editors see the error without the whole page going to pieces. Verified on production: invalid URL → faded preview, valid URL → new thumbnail, save → PDS updated, public list page shows the new hero, revert → clean.
Commit 10dd6ff on src/components/OpportunitySpaceEditor.tsx.
// 1. New section, slotted between description and subfields
<EditSection label="Hero image">
<EditableField
value={record.image ?? ''}
onChange={(v) => setField('image', v)}
className="text-sm text-gray-600 font-mono"
placeholder="https://… (paste any public image URL)"
/>
<ImagePreview url={record.image ?? ''} />
</EditSection>
// 2. New helper at the bottom of the file
function ImagePreview({ url }: { url: string }) {
const trimmed = url.trim()
if (!trimmed) {
return (
<p className="mt-3 text-xs text-gray-400">
No image set. Any public HTTPS URL works (Unsplash, your CMS,
an ATProto blob, …).
</p>
)
}
return (
<div className="mt-3 w-60 h-32 bg-gray-100 rounded overflow-hidden border border-gray-200 relative">
<img
src={trimmed} alt="hero preview"
className="w-full h-full object-cover transition-opacity duration-200"
onError={(e) => { e.currentTarget.style.opacity = '0.25' }}
onLoad={(e) => { e.currentTarget.style.opacity = '1' }}
/>
</div>
)
}
Because the shared component powers the edit routes for all 4 focus areas, this 47-line change turns on image editing for all 15 opportunity-space records at once.
Tested on production as satyam2.climateai.org. Target: Open Compute Networks under AI & Robotics (picked because the public list page shows the image prominently, so the read path is easy to eyeball).
https://example.invalid/…). onError drops the <img> to 25% opacity, leaving the alt text visible as a ghost so editors can tell that the fetch failed without the UI blowing up.
/areas/ai-robotics/opportunity-spaces/ list page renders the new image on the Open Compute Networks card within seconds (Next.js ISR tag invalidation from the PUT).
updatedAt moved to 20:41:11Z.Two PUTs, two confirmations, no drift:
# after the test save (new URL)
$ curl -sG .../com.atproto.repo.getRecord \
--data-urlencode "collection=org.plresearch.opportunitySpace" \
--data-urlencode "rkey=ai--open-compute-networks" | jq '.value | {image, updatedAt}'
{
"image": "https://images.unsplash.com/photo-1639762681485-074b7f938ba0?w=1200&q=80",
"updatedAt": "2026-04-20T20:39:32.498Z"
}
# after the revert save (original URL restored)
{
"image": "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=1200&q=80",
"updatedAt": "2026-04-20T20:41:11.377Z"
}
image is now editable end-to-endUI → PUT → PDS → indexer → ISR → public list page. All 15 opportunity-space records across all four areas gained this capability in one 47-line diff because the editor is shared.
The onError fade-to-25% pattern surfaces errors without breaking the page. Unlike the default browser broken-image icon, the alt text stays visible and the layout doesn’t shift. If the user then pastes a valid URL, onLoad fires and opacity returns to 1.
image: string was already in the lexicon (../../plresearch-indexer/lexicons/org/plresearch/opportunitySpace.json, maxLength 1024). The PUT route (src/app/api/opportunity-spaces/[rkey]/route.ts) was already forwarding body.image to agent.com.atproto.repo.putRecord(…). R4 only needed UI.
As discussed in the design note pre-r4, file upload was the next tier (tier 3 in that note). Current state requires the editor to host the image somewhere first (Unsplash, CMS, etc.). If editors complain, the upgrade path is a new POST /api/opportunity-spaces/upload-image route that uploadBlobs to plresearch.org’s PDS and returns the com.atproto.sync.getBlob URL — still no lexicon change required.
The URL field accepts anything the textbox accepts. Malformed inputs (e.g. javascript:) would be saved to the record as-is, then fail the <img src> fetch and fade in the preview. For a stricter posture, gate the save button on a basic URL.canParse(…) + protocol allowlist (http:, https:, data:image/). Not blocking — the existing image: string / maxLength: 1024 schema is permissive by design.
https://www.plresearch.org/areas/ai-robotics/opportunity-spaces/open-compute-networks/edit
Handlesatyam2.climateai.org
Toolagent-browser (snapshot-driven)
Test URLshttps://example.invalid/broken-image.png (broken)https://images.unsplash.com/photo-1639762681485-074b7f938ba0?w=1200&q=80 (valid, new)https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=1200&q=80 (original, for revert)
PDS verifycurl -sG .../xrpc/com.atproto.repo.getRecord --data-urlencode collection=org.plresearch.opportunitySpace --data-urlencode rkey=ai--open-compute-networks
Previous roundsr1 (fail) · r2 (pass) · r3 (pass)
Screenshotsassets/01-*.png … assets/06-*.png