plresearch-org · edit-ai-opportunity-spaces-r4 · round 4

Round 4: hero image URL field with live preview (tier 2)

@daviddao

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.

Date: 2026-04-20 · Target: open-compute-networks · Previous: r3 (pass) · pass


Headline result

+47 / −0
Line diff (one file)
0
Backend / schema changes
5 / 5
End-to-end steps passed
~20 min
Ship → verified on prod

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.

What changed

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.

Walkthrough

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).

New Hero image section with URL + preview
01New “HERO IMAGE” section between description and subfields. URL renders in monospace for visual distinction, preview thumbnail below at 240×128 with object-fit:cover.
Preview thumbnail further down the edit form
02Scrolled view: the preview sits right below the URL textbox and above the Subfields array, matching the section rhythm of the rest of the editor.
Broken URL — preview fades to 25% opacity
03Deliberate broken URL (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.
Valid new URL — new preview thumbnail loads
04Replacing with a different valid Unsplash URL updates the preview immediately, and the bottom-right Save bar goes to “Unsaved changes”.
Public list page renders the new hero image
05After Save, the public /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).
After revert — URL restored, Save bar idle
06Revert: pasted the original URL back, clicked Save. Bar returned to “Editing mode — click any text to edit”. PDS updatedAt moved to 20:41:11Z.

PDS verification (xrpc)

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"
}

Findings

P1 · image is now editable end-to-end

UI → 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.

P2 · Broken-URL UX is forgiving

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.

P3 · No schema or backend change

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.

I1 · Still URL-only — no drag-drop upload

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.

I2 · No client-side URL validation

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.

Reproducing

URLhttps://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-*.pngassets/06-*.png